Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/delta-command/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ The whole app is small on purpose:
| `DELTA_API_URL` | `http://127.0.0.1:8000` | Backend URL (used by Next.js server-side and rewrites). |
| `DELTA_DATA_PATH` | `backend/config/data.json` | JSON database file. |
| `DELTA_HOST` / `DELTA_PORT` | `127.0.0.1` / `8000` | Backend bind address. |
| `LLM_API_KEY` | *(unset)* | Enables the **AI Executive Briefing** on the dashboard. |
| `LLM_API_KEY` | *(unset)* | Enables the **Delta chat** surface and executive briefing. |
| `LLM_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible endpoint (Groq, Ollama, etc.). |
| `LLM_MODEL` | `gpt-4o-mini` | Model name. |

### AI briefing
### Delta chat

With `LLM_API_KEY` set, a **Generate** button on the dashboard produces a 2–3 paragraph leadership summary of the live pipeline, capacity, and delivery risks. Works with any OpenAI-compatible provider:
With `LLM_API_KEY` set, `/chat` becomes a full conversational surface: composer, message thread, starter prompts, streaming reveal. Every turn sends the current pipeline, team-capacity, and delivery state as the system prompt, so answers stay grounded. The dashboard "Ask Delta" callout deep-links into `/chat?prompt=<id>` and auto-submits the first turn. Works with any OpenAI-compatible provider:

```bash
# OpenAI
Expand Down
7 changes: 4 additions & 3 deletions apps/delta-command/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ uv run delta-command # → http://127.0.0.1:8000
| PATCH | `/api/opportunities` | Update stage (probability auto-set to 100/0 for won/lost). |
| PATCH | `/api/projects` | Update status. |
| PATCH | `/api/timeline` | Update one weekly utilization cell for one engineer; recomputes engineer.utilization and status. |
| POST | `/api/briefing` | Author an executive briefing via an OpenAI-compatible LLM. Returns `503` if `LLM_API_KEY` is not set. |
| POST | `/api/chat` | Reply to a conversation `{ messages: [{role, content}] }`. Backend embeds a summary of the live database in the system prompt on every turn, so replies stay grounded without the client sending state. Returns `503` if `LLM_API_KEY` is not set. |
| POST | `/api/briefing` | One-shot executive briefing — a canned first-turn prompt. Kept for CLI / non-chat callers. |

### LLM configuration

`POST /api/briefing` uses an OpenAI-compatible chat completions endpoint:
Both `/api/chat` and `/api/briefing` use an OpenAI-compatible chat completions endpoint:

```
LLM_API_KEY required
Expand Down Expand Up @@ -53,6 +54,6 @@ delta_command/
├── json_db.py # atomic file I/O
├── models.py # Pydantic models (snake_case in Python, camelCase in JSON)
├── store.py # domain operations (load/save/update)
├── briefing.py # LLM-authored executive briefing
├── briefing.py # Chat + one-shot briefing via OpenAI-compatible LLM
└── main.py # FastAPI routes
```
128 changes: 98 additions & 30 deletions apps/delta-command/backend/src/delta_command/briefing.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
"""LLM-generated executive briefings.
"""LLM-backed chat and executive briefing.

The backend summarises the current state into a compact prompt, then calls an
OpenAI-compatible chat completions endpoint. Works with OpenAI, Groq, Ollama,
etc. — anything that speaks the /v1/chat/completions protocol.
The backend summarises the current state into a compact system prompt, then
calls an OpenAI-compatible chat completions endpoint. Works with OpenAI, Groq,
Ollama, etc. — anything that speaks /v1/chat/completions.

Configure via environment:
LLM_API_KEY required (use any placeholder for local Ollama)
LLM_BASE_URL default: https://api.openai.com/v1
LLM_MODEL default: gpt-4o-mini
"""

from __future__ import annotations

import os

import httpx

from delta_command.models import Database, OpportunityStage, ProjectStatus
from delta_command.store import load_database

SYSTEM_PROMPT = (
"You are an executive briefing assistant for a delta engineering operations "
"dashboard. Given the current state, write a concise leadership briefing "
"(2-3 short paragraphs, ~150 words total) that highlights: pipeline health, "
"team capacity concerns, and delivery risks. Lead with what needs attention. "
"Use plain prose, no bullet lists, no headings."
CHAT_SYSTEM_PROMPT = (
"You are Delta, an ops assistant for a delta engineering team. You have live "
"access to pipeline, project, and team capacity data (summarised below). "
"Answer questions concisely — usually 2–4 sentences, sometimes a short bulleted "
"list when it helps clarity. Cite specific names, numbers, and percentages from "
"the data. If a question is outside what the data can support, say so plainly."
)

BRIEFING_SYSTEM_PROMPT = (
"You are Delta, an ops assistant for a delta engineering team. Write a concise "
"executive briefing (2–3 short paragraphs, ~150 words total) that highlights "
"pipeline health, team capacity concerns, and delivery risks. Lead with what "
"needs attention. Cite specific names, numbers, and percentages. Use plain "
"prose — no headings, no bullet lists."
)

BRIEFING_PROMPT = (
"Write today's executive briefing covering pipeline health, team capacity, and "
"delivery risks."
)


Expand All @@ -34,51 +49,85 @@ class LLMError(RuntimeError):
"""Raised when the upstream provider fails."""


ChatMessage = dict[str, str] # {"role": "user"|"assistant", "content": str}


def build_context(db: Database) -> str:
"""Compact state summary used as the LLM user prompt."""
"""Compact state summary embedded into every chat's system prompt."""
engineers = db.engineers
opps = db.opportunities
projects = db.projects

active_opps = [o for o in opps if o.stage not in (OpportunityStage.WON, OpportunityStage.LOST)]
late_stage = [o for o in active_opps if o.stage in (OpportunityStage.PROPOSAL, OpportunityStage.NEGOTIATION)]
active_opps = [
o for o in opps if o.stage not in (OpportunityStage.WON, OpportunityStage.LOST)
]
late_stage = [
o
for o in active_opps
if o.stage in (OpportunityStage.PROPOSAL, OpportunityStage.NEGOTIATION)
]
total_pipeline = sum(o.value for o in active_opps)
weighted = sum(o.value * o.probability / 100 for o in active_opps)

avg_util = round(sum(e.utilization for e in engineers) / len(engineers)) if engineers else 0
avg_util = (
round(sum(e.utilization for e in engineers) / len(engineers)) if engineers else 0
)
overallocated = sorted(
(e for e in engineers if e.utilization >= 100),
key=lambda e: -e.utilization,
)
unassigned = [e for e in engineers if not e.current_projects]
available = [e for e in engineers if e.utilization < 70]

active_projects = [p for p in projects if p.status in (ProjectStatus.ACTIVE, ProjectStatus.AT_RISK)]
active_projects = [
p for p in projects if p.status in (ProjectStatus.ACTIVE, ProjectStatus.AT_RISK)
]
at_risk = [p for p in projects if p.status == ProjectStatus.AT_RISK]
total_budget = sum(p.budget for p in active_projects) or 1
total_spent = sum(p.spent for p in active_projects)

lines = [
f"Team: {len(engineers)} engineers, avg utilization {avg_util}%.",
f"Overallocated ({len(overallocated)}): "
+ (", ".join(f"{e.name} at {e.utilization}%" for e in overallocated[:5]) or "none"),
f"Available for assignment: {len(unassigned)}.",
"Overallocated ({}): {}".format(
len(overallocated),
", ".join(f"{e.name} at {e.utilization}%" for e in overallocated[:8])
or "none",
),
"Available <70% ({}): {}".format(
len(available),
", ".join(f"{e.name} at {e.utilization}%" for e in available[:6])
or "none",
),
"",
f"Pipeline: {len(active_opps)} active opportunities worth ${total_pipeline/1_000_000:.1f}M "
f"(${weighted/1_000_000:.1f}M weighted). {len(late_stage)} in proposal or negotiation.",
f"Pipeline: {len(active_opps)} active opportunities worth "
f"${total_pipeline/1_000_000:.1f}M "
f"(${weighted/1_000_000:.1f}M weighted). "
f"{len(late_stage)} in proposal or negotiation.",
"Top active opportunities: "
+ ", ".join(
f"{o.title} ({o.client}, ${o.value/1000:.0f}K, {o.stage.value}, "
f"{o.probability}%)"
for o in sorted(active_opps, key=lambda o: -o.value)[:5]
),
"",
f"Delivery: {len(active_projects)} projects active, {len(at_risk)} at risk. "
f"Budget burn: ${total_spent/1_000_000:.1f}M of ${total_budget/1_000_000:.1f}M "
f"Budget burn: ${total_spent/1_000_000:.1f}M of "
f"${total_budget/1_000_000:.1f}M "
f"({round(total_spent/total_budget*100)}%).",
]
if at_risk:
lines.append(
"At-risk projects: "
+ ", ".join(f"{p.name} ({p.progress}% complete)" for p in at_risk[:3])
+ ", ".join(
f"{p.name} ({p.progress}% complete, "
f"${p.spent/1000:.0f}K of ${p.budget/1000:.0f}K)"
for p in at_risk[:5]
)
)
return "\n".join(lines)


async def call_llm(system: str, user: str) -> str:
async def call_llm(messages: list[ChatMessage]) -> str:
"""Call the configured LLM with a full message list; return assistant reply."""
api_key = os.environ.get("LLM_API_KEY")
if not api_key:
raise LLMNotConfigured(
Expand All @@ -95,24 +144,43 @@ async def call_llm(system: str, user: str) -> str:
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"messages": messages,
"temperature": 0.4,
},
)
except httpx.HTTPError as exc:
raise LLMError(f"LLM request failed: {exc}") from exc

if response.status_code >= 400:
raise LLMError(f"LLM provider returned {response.status_code}: {response.text[:200]}")
raise LLMError(
f"LLM provider returned {response.status_code}: {response.text[:200]}"
)

try:
return response.json()["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, ValueError) as exc:
raise LLMError(f"Unexpected LLM response shape: {exc}") from exc


def _system_message(system_prompt: str) -> ChatMessage:
"""System prompt + current state, formatted for the model."""
context = build_context(load_database())
return {
"role": "system",
"content": f"{system_prompt}\n\nCurrent state:\n{context}",
}


async def chat(messages: list[ChatMessage]) -> str:
"""Reply to a conversation using the chat system prompt + current data."""
return await call_llm([_system_message(CHAT_SYSTEM_PROMPT), *messages])


async def generate_briefing() -> str:
return await call_llm(SYSTEM_PROMPT, build_context(load_database()))
"""One-shot canned briefing prompt (used by the dashboard entry point)."""
return await call_llm(
[
_system_message(BRIEFING_SYSTEM_PROMPT),
{"role": "user", "content": BRIEFING_PROMPT},
]
)
32 changes: 27 additions & 5 deletions apps/delta-command/backend/src/delta_command/main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Delta Command HTTP API — a thin CRUD layer over a JSON file."""
"""Delta Command HTTP API — a thin CRUD + chat layer over a JSON file."""

import os

import uvicorn
from fastapi import FastAPI, HTTPException

from delta_command.briefing import LLMError, LLMNotConfigured, generate_briefing
from delta_command.briefing import (
LLMError,
LLMNotConfigured,
chat,
generate_briefing,
)
from delta_command.models import (
ChatRequest,
Database,
Opportunity,
OpportunityStageUpdate,
Expand All @@ -21,7 +27,7 @@
update_timeline_cell,
)

app = FastAPI(title="Delta Command API", version="1.1.0")
app = FastAPI(title="Delta Command API", version="1.2.0")


@app.get("/api/state")
Expand Down Expand Up @@ -49,14 +55,30 @@ def patch_project(body: ProjectStatusUpdate) -> Project:
@app.patch("/api/timeline")
def patch_timeline(body: TimelineCellUpdate) -> Database:
try:
return update_timeline_cell(body.engineer_id, body.week_start, body.utilization, body.note)
return update_timeline_cell(
body.engineer_id, body.week_start, body.utilization, body.note
)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc


@app.post("/api/chat")
async def post_chat(body: ChatRequest) -> dict[str, str]:
"""Reply to a conversation. The system prompt embeds the current database."""
if not body.messages:
raise HTTPException(400, "messages must not be empty")
try:
reply = await chat([m.model_dump() for m in body.messages])
except LLMNotConfigured as exc:
raise HTTPException(503, str(exc)) from exc
except LLMError as exc:
raise HTTPException(502, str(exc)) from exc
return {"reply": reply}


@app.post("/api/briefing")
async def post_briefing() -> dict[str, str]:
"""Generate an LLM-authored executive briefing from the current state."""
"""One-shot executive briefing — a canned first-turn prompt."""
try:
briefing = await generate_briefing()
except LLMNotConfigured as exc:
Expand Down
14 changes: 14 additions & 0 deletions apps/delta-command/backend/src/delta_command/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,17 @@ class TimelineCellUpdate(BaseModel):
week_start: str
utilization: int
note: str | None = None


class ChatRole(StrEnum):
USER = "user"
ASSISTANT = "assistant"


class ChatTurn(BaseModel):
role: ChatRole
content: str


class ChatRequest(BaseModel):
messages: list[ChatTurn]
66 changes: 66 additions & 0 deletions apps/delta-command/backend/tests/test_briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,69 @@ def fake_handler(request: httpx.Request) -> httpx.Response:
response = client.post("/api/briefing")
assert response.status_code == 502
assert "500" in response.json()["detail"]


def test_chat_returns_400_when_messages_empty(client: TestClient) -> None:
response = client.post("/api/chat", json={"messages": []})
assert response.status_code == 400


def test_chat_returns_503_when_llm_not_configured(client: TestClient) -> None:
response = client.post(
"/api/chat",
json={"messages": [{"role": "user", "content": "Who is overallocated?"}]},
)
assert response.status_code == 503


def test_chat_returns_reply_and_forwards_full_history(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LLM_API_KEY", "test-key")
monkeypatch.setenv("LLM_BASE_URL", "https://example.test/v1")
monkeypatch.setenv("LLM_MODEL", "test-model")

captured: dict[str, Any] = {}
real_async_client = httpx.AsyncClient

def fake_handler(request: httpx.Request) -> httpx.Response:
import json

captured["payload"] = json.loads(request.content)
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"content": "Marcus, Lisa, Ryan, Sophie, and Maya are overallocated."
}
}
]
},
)

monkeypatch.setattr(
"delta_command.briefing.httpx.AsyncClient",
lambda *a, **k: real_async_client(transport=httpx.MockTransport(fake_handler)),
)

response = client.post(
"/api/chat",
json={
"messages": [
{"role": "user", "content": "Who is overallocated?"},
]
},
)
assert response.status_code == 200
assert "Marcus" in response.json()["reply"]

payload = captured["payload"]
assert payload["model"] == "test-model"
assert payload["messages"][0]["role"] == "system"
assert "Current state:" in payload["messages"][0]["content"]
assert payload["messages"][1] == {
"role": "user",
"content": "Who is overallocated?",
}
Loading