Skip to content

Commit ae146f6

Browse files
authored
feat: AI-generated descriptions for contracts
feat: AI-generated descriptions for contracts
2 parents 370fc92 + af75372 commit ae146f6

File tree

5 files changed

+186
-3
lines changed

5 files changed

+186
-3
lines changed

dashboard/src/lib/api/contracts.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,15 @@ export function getContractUsage(
116116
`/contracts/${encodeURIComponent(contractId)}/usage`,
117117
)
118118
}
119+
120+
export function generateDescription(body: {
121+
name: string
122+
type: string
123+
definition_yaml: string
124+
tags?: string[]
125+
}): Promise<{ description: string }> {
126+
return request<{ description: string }>("/contracts/generate-description", {
127+
method: "POST",
128+
body: JSON.stringify(body),
129+
})
130+
}

dashboard/src/lib/api/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export { listBundles, listBundleVersions, uploadBundle, deployBundle, getBundleY
66
export { getAgentStatus, getAgentCoverage, getFleetCoverage, getAgentHistory } from "./agents"
77
export { getStatsOverview, getContractStats } from "./stats"
88
export { listChannels, createChannel, updateChannel, deleteChannel, testChannel, rotateSigningKey, purgeEvents, getAiConfig, updateAiConfig, deleteAiConfig, testAiConnection, getAiUsage } from "./settings"
9-
export { listContracts, getContract, getContractVersion, createContract, updateContract, deleteContract, importContracts, getContractUsage } from "./contracts"
9+
export { listContracts, getContract, getContractVersion, createContract, updateContract, deleteContract, importContracts, getContractUsage, generateDescription } from "./contracts"
1010
export { listCompositions, getComposition, createComposition, updateComposition, deleteComposition, previewComposition, deployComposition } from "./compositions"
1111

1212
export type { HealthResponse, HealthDetailsResponse, ServiceHealth, UserInfo, SetupResponse, ApiKeyInfo, CreateKeyResponse } from "./auth"

dashboard/src/pages/contracts/contract-editor-dialog.tsx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
import { Alert, AlertDescription } from "@/components/ui/alert"
1515
import { YamlEditor } from "@/components/yaml-editor"
1616
import { useYamlValidation } from "@/hooks/use-yaml-validation"
17-
import { createContract, updateContract, type LibraryContract } from "@/lib/api/contracts"
17+
import { createContract, updateContract, generateDescription, type LibraryContract } from "@/lib/api/contracts"
18+
import { getAiConfig } from "@/lib/api/settings"
19+
import {
20+
Tooltip, TooltipContent, TooltipTrigger,
21+
} from "@/components/ui/tooltip"
1822
import { AiChatPanel } from "./ai-chat-panel"
1923

2024
const CONTRACT_TYPES = ["pre", "post", "session", "sandbox"] as const
@@ -58,8 +62,17 @@ export function ContractEditorDialog({
5862
const [definition, setDefinition] = useState("")
5963
const [saving, setSaving] = useState(false)
6064
const [error, setError] = useState<string | null>(null)
65+
const [aiConfigured, setAiConfigured] = useState(false)
66+
const [generatingDesc, setGeneratingDesc] = useState(false)
6167
const { validation, validate, reset } = useYamlValidation()
6268

69+
// Check AI config on mount
70+
useEffect(() => {
71+
getAiConfig()
72+
.then((cfg) => setAiConfigured(cfg.configured))
73+
.catch(() => setAiConfigured(false))
74+
}, [])
75+
6376
// Reset form when dialog opens or contract changes
6477
useEffect(() => {
6578
if (!open) return
@@ -129,6 +142,28 @@ export function ContractEditorDialog({
129142
}
130143
}
131144

145+
const handleGenerateDescription = async () => {
146+
if (!definition.trim() || !name.trim()) {
147+
toast.error("Name and definition are required to generate a description")
148+
return
149+
}
150+
setGeneratingDesc(true)
151+
try {
152+
const tagList = tags.split(",").map((t) => t.trim()).filter(Boolean)
153+
const result = await generateDescription({
154+
name,
155+
type,
156+
definition_yaml: definition,
157+
tags: tagList.length > 0 ? tagList : undefined,
158+
})
159+
setDescription(result.description)
160+
} catch {
161+
toast.error("Failed to generate description")
162+
} finally {
163+
setGeneratingDesc(false)
164+
}
165+
}
166+
132167
const idValid = !contractId || ID_REGEX.test(contractId)
133168

134169
const handleSave = async () => {
@@ -233,7 +268,27 @@ export function ContractEditorDialog({
233268

234269
<div className="grid grid-cols-2 gap-3">
235270
<div className="space-y-1.5">
236-
<Label htmlFor="contract-desc">Description</Label>
271+
<div className="flex items-center gap-1.5">
272+
<Label htmlFor="contract-desc">Description</Label>
273+
{aiConfigured && (
274+
<Tooltip>
275+
<TooltipTrigger asChild>
276+
<Button
277+
variant="ghost" size="icon"
278+
className="size-5"
279+
disabled={generatingDesc || !definition.trim() || !name.trim()}
280+
onClick={handleGenerateDescription}
281+
>
282+
{generatingDesc
283+
? <Loader2 className="size-3 animate-spin" />
284+
: <Sparkles className="size-3" />
285+
}
286+
</Button>
287+
</TooltipTrigger>
288+
<TooltipContent>Generate with AI</TooltipContent>
289+
</Tooltip>
290+
)}
291+
</div>
237292
<Input id="contract-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional" />
238293
</div>
239294
<div className="space-y-1.5">

src/edictum_server/routes/ai.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from edictum_server.schemas.ai import (
1818
AiConfigResponse,
1919
AssistRequest,
20+
GenerateDescriptionRequest,
21+
GenerateDescriptionResponse,
2022
TestConnectionResponse,
2123
UpsertAiConfigRequest,
2224
)
@@ -162,6 +164,103 @@ async def test_connection(
162164
await provider.close()
163165

164166

167+
@router.post(
168+
"/api/v1/contracts/generate-description",
169+
response_model=GenerateDescriptionResponse,
170+
)
171+
async def generate_description(
172+
body: GenerateDescriptionRequest,
173+
auth: AuthContext = Depends(require_dashboard_auth),
174+
db: AsyncSession = Depends(get_db),
175+
settings: Settings = Depends(get_settings),
176+
) -> GenerateDescriptionResponse:
177+
"""Generate a one-line description for a contract from its YAML definition."""
178+
config = await get_ai_config(db, auth.tenant_id)
179+
if not config:
180+
raise HTTPException(status_code=503, detail="AI assistant not configured")
181+
182+
api_key: str | None = None
183+
provider = None
184+
try:
185+
secret = settings.get_signing_secret()
186+
if config.api_key_encrypted:
187+
api_key = decrypt_api_key(config.api_key_encrypted, secret)
188+
189+
from edictum_server.ai import create_provider
190+
191+
provider = create_provider(
192+
provider=config.provider,
193+
api_key=api_key,
194+
model=config.model,
195+
base_url=config.base_url,
196+
)
197+
198+
tags_str = ", ".join(body.tags) if body.tags else "none"
199+
prompt = (
200+
"Generate a concise one-sentence description for this edictum contract. "
201+
"The description should explain what the contract does in plain English "
202+
"(what it checks and what action it takes). Do not include the contract "
203+
"name or ID. Output ONLY the description text, nothing else.\n\n"
204+
f"Name: {body.name}\n"
205+
f"Type: {body.type}\n"
206+
f"Tags: {tags_str}\n"
207+
f"Definition:\n```yaml\n{body.definition_yaml}\n```"
208+
)
209+
210+
start = time.monotonic()
211+
chunks: list[str] = []
212+
async for chunk in provider.stream_response(
213+
messages=[{"role": "user", "content": prompt}],
214+
system_prompt=(
215+
"You are a technical writer for edictum, an AI agent governance system. "
216+
"You write clear, concise descriptions for governance contracts. "
217+
"Respond with ONLY the description — no quotes, no prefix, no explanation."
218+
),
219+
max_tokens=150,
220+
):
221+
chunks.append(chunk)
222+
if time.monotonic() - start > 15:
223+
break # 15s safety timeout
224+
225+
description = "".join(chunks).strip().strip('"').strip("'")
226+
227+
# Log usage
228+
elapsed = int((time.monotonic() - start) * 1000)
229+
usage = provider.last_usage
230+
if usage:
231+
from edictum_server.ai.pricing import estimate_cost, fetch_model_pricing
232+
233+
pricing = await fetch_model_pricing(provider.model, config.provider)
234+
cost = estimate_cost(usage.input_tokens, usage.output_tokens, pricing)
235+
await log_usage(
236+
tenant_id=auth.tenant_id,
237+
provider_name=config.provider,
238+
model=provider.model,
239+
input_tokens=usage.input_tokens,
240+
output_tokens=usage.output_tokens,
241+
total_tokens=usage.input_tokens + usage.output_tokens,
242+
duration_ms=elapsed,
243+
cost=cost,
244+
)
245+
246+
return GenerateDescriptionResponse(description=description)
247+
except HTTPException:
248+
raise
249+
except Exception as exc:
250+
logger.warning(
251+
"AI description generation failed for tenant %s: %s",
252+
auth.tenant_id,
253+
exc,
254+
)
255+
err_msg = str(exc)
256+
if api_key and api_key in err_msg:
257+
err_msg = err_msg.replace(api_key, "***")
258+
raise HTTPException(status_code=503, detail=err_msg) from exc
259+
finally:
260+
if provider:
261+
await provider.close()
262+
263+
165264
@router.post("/api/v1/contracts/assist")
166265
async def assist(
167266
body: AssistRequest,

src/edictum_server/schemas/ai.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from typing import Annotated
6+
57
from pydantic import BaseModel, Field
68

79

@@ -47,6 +49,21 @@ class AssistRequest(BaseModel):
4749
current_yaml: str | None = Field(None, max_length=50_000)
4850

4951

52+
class GenerateDescriptionRequest(BaseModel):
53+
"""Request to generate a description from contract metadata."""
54+
55+
name: str = Field(..., max_length=255)
56+
type: str = Field(..., max_length=32)
57+
definition_yaml: str = Field(..., max_length=50_000)
58+
tags: list[Annotated[str, Field(max_length=64)]] = Field(default_factory=list, max_length=20)
59+
60+
61+
class GenerateDescriptionResponse(BaseModel):
62+
"""AI-generated description for a contract."""
63+
64+
description: str
65+
66+
5067
class DailyUsage(BaseModel):
5168
"""Aggregated AI usage for a single day."""
5269

0 commit comments

Comments
 (0)