"""信息安全自评估 — AI narrative drafting via Bailian (Qwen). Drafts the 评估描述 + 评估结论 prose for each report section from the auto-pulled numbers, so the auditor edits a first draft instead of a blank page. Gated on the platform LLM key (reuses the ``ai_spending.llm.*`` ``platform_settings`` rows); no key → ``AppException(BAILIAN_LLM_NO_KEY, 503)``, which the router surfaces and the UI handles by disabling the button. """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.domains.auditor import assessment_catalog as cat from app.domains.auditor.models import ( AuditorAssessmentCycle, AuditorAssessmentSection, ) from app.integrations.models import PlatformSetting logger = logging.getLogger(__name__) # Reuse the platform-wide Bailian config rows (same as ai_spending). _KEY_API = "ai_spending.llm.api_key" _KEY_MODEL = "ai_spending.llm.model" _KEY_REGION = "ai_spending.llm.region" async def _setting(db: AsyncSession, key: str) -> str | None: row = await db.get(PlatformSetting, key) v = row.value if row else None return v if isinstance(v, str) else None async def _build_client(db: AsyncSession): from app.domains.bailian.llm_client import BailianLLMClient api_key = (await _setting(db, _KEY_API) or "").strip() if not api_key: raise AppException( code="BAILIAN_LLM_NO_KEY", message=( "未配置百炼(DashScope) API Key,无法 AI 起草。请在 管理 → " "连接器 → AI / LLM 配置后重试。" ), status_code=503, ) model = await _setting(db, _KEY_MODEL) region = await _setting(db, _KEY_REGION) return BailianLLMClient(api_key=api_key, model=model, region=region, purpose="auditor.assessment") _SYSTEM = ( "你是一名资深信息安全审计师,精通 ISO/IEC 27001、SOC 2 与中国网络安全等级保护" "(等保)。请基于给定的某一控制域的客观数据,撰写该域的「评估描述」与「评估" "结论」。评估描述用简体中文,2-4 句,专业、克制、就事论事,不夸大;评估结论" "在 compliant(符合)/partial(部分符合)/non_compliant(不符合)/" "not_assessed(未评估)中选一个。只输出 JSON。" ) def _section_user_prompt( *, domain_title: str, section_title: str, period: str, auto_summary: str | None, table: list[dict[str, Any]] | None, ) -> str: lines = [ f"评估时间范围:{period}", f"控制域:{domain_title}", f"小节:{section_title}", ] if auto_summary: lines.append(f"客观数据摘要:{auto_summary}") if table: lines.append("数据明细:") for row in table[:30]: lines.append(" - " + ", ".join( f"{k}={v}" for k, v in row.items())) lines.append( '请输出 JSON:{"narrative": "评估描述", "conclusion": ' '"compliant|partial|non_compliant|not_assessed"}' ) return "\n".join(lines) async def _llm_draft_section( *, section_title: str, section_key: str, period: str, domain_title: str, auto_summary: str | None, data_table: list[dict[str, Any]] | None, client: Any, ) -> dict[str, Any] | None: """Call the LLM for one section. Returns the parsed JSON, or None on a transient per-section error so the batch can skip + continue. Takes plain inputs (not the ORM row) so the caller can issue this network call OUTSIDE any DB transaction / between commits — the row is fetched fresh and mutated only after the call returns. """ prompt = _section_user_prompt( domain_title=domain_title, section_title=section_title, period=period, auto_summary=auto_summary, table=data_table, ) try: return await client.chat_json(system=_SYSTEM, user=prompt, max_tokens=900) except Exception: # noqa: BLE001 # Per-section transient LLM errors (BAD_JSON / rate-limit / network, # all AppException 502s) must NOT abort the whole batch and discard # the sections already drafted — skip this one and continue. The # no-key 503 is raised by ``_build_client`` BEFORE the loop, so it # still propagates cleanly. logger.exception("AI draft failed for section %s", section_key) return None def _overall_from_sections(sections: list[AuditorAssessmentSection]) -> str: """Deterministic roll-up: worst applicable conclusion wins.""" applicable = [ s.conclusion for s in sections if s.conclusion_applicable and s.conclusion ] if not applicable: return "not_assessed" if "non_compliant" in applicable: return "non_compliant" if "partial" in applicable: return "partial" if any(c == "not_assessed" for c in applicable): return "partial" # incomplete assessment → not yet fully compliant return "compliant" async def draft_cycle(db: AsyncSession, cycle_id: uuid.UUID) -> dict[str, Any]: """AI-draft every section of a cycle + roll up the overall conclusion. Persists INCREMENTALLY — each section commits as soon as it is drafted. Drafting all ~20 sections is a long sequential run of LLM calls (well past a typical reverse-proxy idle timeout), so a single deferred commit at the end would discard every (paid) section if the request is cut off mid-flight. With per-section commits, a timeout leaves the already-drafted sections persisted and the auditor can simply re-run to fill the rest. """ cycle = await db.get(AuditorAssessmentCycle, cycle_id) if cycle is None: raise AppException(code="NOT_FOUND", message="cycle not found", status_code=404) if cycle.status == "finalized": raise AppException( code="ASSESSMENT_FINALIZED", message="该报告已定稿,请先重新打开再 AI 起草", status_code=400, ) sections = (await db.execute( select(AuditorAssessmentSection) .where(AuditorAssessmentSection.cycle_id == cycle_id) .order_by(AuditorAssessmentSection.sort_order) )).scalars().all() by_key = {s.section_key: s for s in sections} cycle_title = cycle.title # Snapshot the per-section prompt inputs UP FRONT — the ORM rows expire # on the incremental commits below, and touching an expired attr inside # the loop would trigger lazy IO (MissingGreenlet in async). Each plan # entry is plain data. plan: list[dict[str, Any]] = [] for s in sections: domain_title = ( by_key[s.parent_key].title if s.parent_key and s.parent_key in by_key else cycle_title ) plan.append({ "id": s.id, "section_key": s.section_key, "section_title": s.title, "domain_title": domain_title, "conclusion_applicable": s.conclusion_applicable, "auto_summary": (s.auto_data or {}).get("summary") if s.auto_data else None, "data_table": s.data_table, }) client = await _build_client(db) period = f"{cycle.period_start.isoformat()} ~ {cycle.period_end.isoformat()}" drafted = 0 for item in plan: result = await _llm_draft_section( section_title=item["section_title"], section_key=item["section_key"], period=period, domain_title=item["domain_title"], auto_summary=item["auto_summary"], data_table=item["data_table"], client=client, ) if result is None: continue narrative = (result.get("narrative") or "").strip() if not narrative: continue # Fetch the row fresh (prior commits expired the originals) and # persist this single section before moving to the next LLM call. section = await db.get(AuditorAssessmentSection, item["id"]) if section is None or section.cycle_id != cycle_id: continue section.narrative = narrative if item["conclusion_applicable"]: concl = (result.get("conclusion") or "").strip() if concl in cat.CONCLUSIONS: section.conclusion = concl section.ai_drafted = True await db.commit() drafted += 1 # Roll up the overall conclusion from the now-persisted section set — # but only when this run actually produced narratives. A zero-output # draft (e.g. the LLM rate-limited every section) must not flip a # never-assessed cycle from "not_assessed" to "partial": every section # is still at its initial not_assessed conclusion, and rolling that up # would mislead a report awaiting sign-off into a false posture. fresh = (await db.execute( select(AuditorAssessmentSection) .where(AuditorAssessmentSection.cycle_id == cycle_id) )).scalars().all() cyc = await db.get(AuditorAssessmentCycle, cycle_id) if cyc is not None and drafted > 0: cyc.overall_conclusion = _overall_from_sections(fresh) await db.commit() overall = cyc.overall_conclusion elif cyc is not None: # Zero-output run: leave the cycle's existing conclusion untouched. overall = cyc.overall_conclusion else: overall = "not_assessed" return {"drafted": drafted, "overall_conclusion": overall}