"""安全问卷 · 答案库 (answer library) — service layer. The reusable, bilingual, owner-curated Q&A knowledge base behind the 乙方侧 flow (customer questionnaires). Every answered inbound question can be backfilled here so the library compounds — the next questionnaire is faster to fill. Entries link the evidence documents that support them and carry a review cadence so stale answers surface. Naive-UTC datetime discipline (codebase-wide MySQL/TiDB convention). """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError, ValidationError from app.domains.auditor.models import AuditorAnswerLibraryEntry from app.identity.person_models import IdentityPerson STATUSES: tuple[str, ...] = ("active", "archived") def _naive_now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) def _to_naive(dt: datetime | None) -> datetime | None: if dt is None: return None if dt.tzinfo is not None: return dt.astimezone(UTC).replace(tzinfo=None) return dt async def _persons_index( db: AsyncSession, ids: set[uuid.UUID], ) -> dict[uuid.UUID, IdentityPerson]: ids = {i for i in ids if i} if not ids: return {} rows = (await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(list(ids))) )).scalars().all() return {p.id: p for p in rows} def _person_brief(p: IdentityPerson | None) -> dict[str, Any] | None: if p is None: return None return {"id": str(p.id), "display_name": p.display_name, "work_email": p.work_email} def entry_view( e: AuditorAnswerLibraryEntry, *, owner: IdentityPerson | None = None, ) -> dict[str, Any]: last_reviewed = _to_naive(e.last_reviewed_at) stale = False if last_reviewed is not None: age_days = (_naive_now() - last_reviewed).days stale = age_days > e.review_interval_months * 30 else: stale = True # never reviewed return { "id": str(e.id), "category": e.category, "question_zh": e.question_zh, "question_en": e.question_en, "answer_zh": e.answer_zh, "answer_en": e.answer_en, "tags": e.tags or [], "owner": _person_brief(owner) if owner else None, "owner_person_id": str(e.owner_person_id) if e.owner_person_id else None, "status": e.status, "usage_count": e.usage_count, # Employee-facing 安全 FAQ state. Kept on the admin view (and only # there) so the console can show, per row, whether ~500 colleagues # can read this answer and how it landed with them. "employee_visible": bool(e.employee_visible), "is_featured": bool(e.is_featured), "employee_view_count": e.employee_view_count, "employee_helpful_count": e.employee_helpful_count, "employee_unhelpful_count": e.employee_unhelpful_count, "last_reviewed_at": last_reviewed.isoformat() if last_reviewed else None, "review_interval_months": e.review_interval_months, "is_stale": stale, "source_doc_ids": e.source_doc_ids or [], "created_at": _to_naive(e.created_at).isoformat() if e.created_at else None, "updated_at": _to_naive(e.updated_at).isoformat() if e.updated_at else None, } async def get_entry(db: AsyncSession, entry_id: uuid.UUID) -> AuditorAnswerLibraryEntry: e = await db.get(AuditorAnswerLibraryEntry, entry_id) if e is None: raise NotFoundError("answer library entry not found") return e async def list_entries( db: AsyncSession, *, category: str | None = None, status: str | None = "active", search: str | None = None, employee_visible: bool | None = None, is_featured: bool | None = None, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: conds = [] if category: conds.append(AuditorAnswerLibraryEntry.category == category) if status: conds.append(AuditorAnswerLibraryEntry.status == status) if employee_visible is not None: conds.append( AuditorAnswerLibraryEntry.employee_visible.is_(employee_visible) ) if is_featured is not None: conds.append(AuditorAnswerLibraryEntry.is_featured.is_(is_featured)) if search: like = f"%{search.strip()}%" conds.append(or_( AuditorAnswerLibraryEntry.question_zh.ilike(like), AuditorAnswerLibraryEntry.question_en.ilike(like), AuditorAnswerLibraryEntry.answer_zh.ilike(like), AuditorAnswerLibraryEntry.answer_en.ilike(like), )) base = select(AuditorAnswerLibraryEntry) for c in conds: base = base.where(c) total = (await db.execute( select(func.count()).select_from(base.subquery()) )).scalar_one() rows = (await db.execute( base.order_by( AuditorAnswerLibraryEntry.usage_count.desc(), AuditorAnswerLibraryEntry.updated_at.desc(), ).offset((page - 1) * page_size).limit(page_size) )).scalars().all() persons = await _persons_index(db, {r.owner_person_id for r in rows}) return { "items": [entry_view(r, owner=persons.get(r.owner_person_id)) for r in rows], "meta": {"page": page, "page_size": page_size, "total": total}, } async def list_active_for_matching( db: AsyncSession, *, limit: int = 400, ) -> list[AuditorAnswerLibraryEntry]: """Compact set of active entries handed to the AI matcher.""" return list((await db.execute( select(AuditorAnswerLibraryEntry).where( AuditorAnswerLibraryEntry.status == "active", ).order_by(AuditorAnswerLibraryEntry.usage_count.desc()).limit(limit) )).scalars().all()) async def create_entry( db: AsyncSession, *, payload: dict[str, Any], actor_id: uuid.UUID | None, ) -> AuditorAnswerLibraryEntry: for f in ("question_zh", "question_en", "answer_zh", "answer_en"): if not (payload.get(f) or "").strip(): raise ValidationError(f"{f} is required") e = AuditorAnswerLibraryEntry( category=(payload.get("category") or "general").strip(), question_zh=payload["question_zh"].strip(), question_en=payload["question_en"].strip(), answer_zh=payload["answer_zh"].strip(), answer_en=payload["answer_en"].strip(), tags=payload.get("tags") or None, owner_person_id=payload.get("owner_person_id") or actor_id, status="active", review_interval_months=int(payload.get("review_interval_months", 12)), last_reviewed_at=_naive_now(), source_doc_ids=payload.get("source_doc_ids") or None, employee_visible=bool(payload.get("employee_visible", True)), is_featured=bool(payload.get("is_featured", False)), created_by_person_id=actor_id, ) db.add(e) await db.flush() return e async def update_entry( db: AsyncSession, entry_id: uuid.UUID, *, payload: dict[str, Any], ) -> AuditorAnswerLibraryEntry: e = await get_entry(db, entry_id) for f in ("category", "question_zh", "question_en", "answer_zh", "answer_en"): if f in payload and payload[f] is not None: setattr(e, f, payload[f].strip()) if "tags" in payload: e.tags = payload["tags"] or None if "owner_person_id" in payload: e.owner_person_id = payload["owner_person_id"] if "source_doc_ids" in payload: e.source_doc_ids = payload["source_doc_ids"] or None if "review_interval_months" in payload and payload["review_interval_months"]: e.review_interval_months = int(payload["review_interval_months"]) # Explicit ``is not None`` rather than truthiness: turning employee # visibility OFF sends ``False``, which a truthiness check would # silently drop — the exact edit that has to work. if payload.get("employee_visible") is not None: e.employee_visible = bool(payload["employee_visible"]) if payload.get("is_featured") is not None: e.is_featured = bool(payload["is_featured"]) if "status" in payload and payload["status"]: if payload["status"] not in STATUSES: raise ValidationError(f"status must be one of {STATUSES}") e.status = payload["status"] if any(f in payload for f in _EMBEDDED_FIELDS): # The text this row's semantic vector was computed from has moved, # so the vector no longer represents it. Dropping it is the honest # state: 安全 FAQ falls back to lexical retrieval for this entry # until it is re-embedded (immediately in the background by the # router, and by the daily backfill if that never lands). # # NOT a timestamp comparison (``embedded_at < updated_at``): those # two clocks are the app's and the database's, and ``updated_at`` # is written by the very UPDATE that stores the vector — a # comparison would report every freshly embedded row as pending # forever, and re-embed the whole library nightly. e.embedding = None e.embedding_model = None e.embedded_at = None await db.flush() return e #: Fields that feed ``security_faq_rag.embedding_text``. Editing any of them #: invalidates the stored vector; editing ``is_featured`` or the owner does not. _EMBEDDED_FIELDS: tuple[str, ...] = ( "category", "question_zh", "question_en", "answer_zh", "answer_en", "tags", ) async def mark_reviewed( db: AsyncSession, entry_id: uuid.UUID, ) -> AuditorAnswerLibraryEntry: e = await get_entry(db, entry_id) e.last_reviewed_at = _naive_now() await db.flush() return e async def increment_usage(db: AsyncSession, entry_id: uuid.UUID) -> None: e = await db.get(AuditorAnswerLibraryEntry, entry_id) if e is not None: e.usage_count = (e.usage_count or 0) + 1 await db.flush() async def delete_entry(db: AsyncSession, entry_id: uuid.UUID) -> None: e = await get_entry(db, entry_id) await db.delete(e) await db.flush() async def backfill_from_answer( db: AsyncSession, *, question_text: str, answer_text: str, category: str, actor_id: uuid.UUID | None, existing_entry_id: uuid.UUID | None = None, ) -> AuditorAnswerLibraryEntry: """Create or update a library entry from an answered inbound question. The answer is stored in BOTH language slots (the inbound answer is single-language as authored); the owner can later translate/split. If ``existing_entry_id`` is given, refresh that entry's answer instead of creating a duplicate. """ q = (question_text or "").strip() a = (answer_text or "").strip() if not q or not a: raise ValidationError("question and answer are required to backfill") if existing_entry_id is not None: e = await get_entry(db, existing_entry_id) e.answer_zh = a e.answer_en = a e.last_reviewed_at = _naive_now() await db.flush() return e e = AuditorAnswerLibraryEntry( category=(category or "general").strip(), question_zh=q, question_en=q, answer_zh=a, answer_en=a, owner_person_id=actor_id, status="active", last_reviewed_at=_naive_now(), # Backfilled entries are the one kind that does NOT default to # employee-visible. They are verbatim prose written for one # customer, in whichever language it was authored, copied into # both language slots — which is fine as questionnaire raw # material and wrong as a page 500 colleagues read. The console # surfaces them as 待放出 so they get edited rather than lost. employee_visible=False, created_by_person_id=actor_id, ) db.add(e) await db.flush() return e async def overview(db: AsyncSession) -> dict[str, Any]: total = (await db.execute( select(func.count()).select_from(AuditorAnswerLibraryEntry).where( AuditorAnswerLibraryEntry.status == "active", ) )).scalar_one() by_cat = dict((await db.execute( select(AuditorAnswerLibraryEntry.category, func.count()).where( AuditorAnswerLibraryEntry.status == "active", ).group_by(AuditorAnswerLibraryEntry.category) )).all()) # Stale = never reviewed OR older than its interval. Approximate with a # coarse 12-month cutoff for the headline (per-entry precise flag is in # entry_view); this is just a dashboard number. now = _naive_now() rows = (await db.execute( select( AuditorAnswerLibraryEntry.last_reviewed_at, AuditorAnswerLibraryEntry.review_interval_months, ).where(AuditorAnswerLibraryEntry.status == "active") )).all() stale = 0 for last, interval in rows: last_n = _to_naive(last) if last_n is None or (now - last_n).days > (interval or 12) * 30: stale += 1 return {"active_total": total, "by_category": by_cat, "stale": stale}