|
17 | 17 | from edictum_server.schemas.ai import ( |
18 | 18 | AiConfigResponse, |
19 | 19 | AssistRequest, |
| 20 | + GenerateDescriptionRequest, |
| 21 | + GenerateDescriptionResponse, |
20 | 22 | TestConnectionResponse, |
21 | 23 | UpsertAiConfigRequest, |
22 | 24 | ) |
@@ -162,6 +164,103 @@ async def test_connection( |
162 | 164 | await provider.close() |
163 | 165 |
|
164 | 166 |
|
| 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 | + |
165 | 264 | @router.post("/api/v1/contracts/assist") |
166 | 265 | async def assist( |
167 | 266 | body: AssistRequest, |
|
0 commit comments