"""Project Knowledge Graph service. 第一阶段目标: 1. 构建统一实体/关系/证据写入能力 2. 支持结构化数据(project/task/signal/user)同步 3. 支持代码目录扫描快照 4. 提供图谱概览统计 """ from __future__ import annotations import hashlib import json import os from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Optional from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( IngestEvent, IngestOffset, KGEvidence, KGEntity, KGRelation, KGSyncRun, Project, FeishuItem, Signal, SymbolDefinition, SymbolReference, Task, User, ) from .context_cache import context_cache_service from .symbol_index_service import SymbolIndexService from ..utils.timezone import now_cn_naive def _utc_now_naive() -> datetime: return now_cn_naive() DDL_BLOCKED_PREFIXES = [ "CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", "COMMENT", "GRANT", "REVOKE", ] DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only" DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程" class ProjectKnowledgeGraphService: def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) async def upsert_entity( self, *, entity_type: str, entity_key: str, display_name: str, payload: Optional[dict[str, Any]] = None, source_type: str = "system", source_ref: Optional[str] = None, ) -> KGEntity: row = ( await self.db.execute( select(KGEntity).where( and_( KGEntity.tenant_id == self.tenant_id, KGEntity.entity_type == str(entity_type).strip()[:40], KGEntity.entity_key == str(entity_key).strip()[:255], ) ) ) ).scalar_one_or_none() if row is None: row = KGEntity( tenant_id=self.tenant_id, entity_type=str(entity_type).strip()[:40], entity_key=str(entity_key).strip()[:255], display_name=str(display_name or entity_key).strip()[:255], payload=payload or {}, source_type=str(source_type or "system").strip()[:32], source_ref=(str(source_ref).strip()[:255] if source_ref else None), last_seen_at=_utc_now_naive(), ) self.db.add(row) await self.db.flush() return row row.display_name = str(display_name or row.display_name).strip()[:255] row.payload = payload or {} row.source_type = str(source_type or row.source_type or "system").strip()[:32] row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref row.last_seen_at = _utc_now_naive() await self.db.flush() return row async def upsert_relation( self, *, relation_type: str, subject_entity_id: int, object_entity_id: int, weight: float = 1.0, payload: Optional[dict[str, Any]] = None, source_type: str = "system", source_ref: Optional[str] = None, ) -> KGRelation: row = ( await self.db.execute( select(KGRelation).where( and_( KGRelation.tenant_id == self.tenant_id, KGRelation.relation_type == str(relation_type).strip()[:40], KGRelation.subject_entity_id == int(subject_entity_id), KGRelation.object_entity_id == int(object_entity_id), ) ) ) ).scalar_one_or_none() if row is None: row = KGRelation( tenant_id=self.tenant_id, relation_type=str(relation_type).strip()[:40], subject_entity_id=int(subject_entity_id), object_entity_id=int(object_entity_id), weight=float(weight), payload=payload or {}, source_type=str(source_type or "system").strip()[:32], source_ref=(str(source_ref).strip()[:255] if source_ref else None), ) self.db.add(row) await self.db.flush() return row row.weight = float(weight) row.payload = payload or {} row.source_type = str(source_type or row.source_type or "system").strip()[:32] row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref await self.db.flush() return row async def add_evidence( self, *, evidence_type: str, content_excerpt: str, entity_id: Optional[int] = None, relation_id: Optional[int] = None, source_type: str = "system", source_ref: Optional[str] = None, metadata: Optional[dict[str, Any]] = None, ) -> KGEvidence: clean = str(content_excerpt or "").strip() digest = hashlib.md5(clean.encode("utf-8")).hexdigest() row = KGEvidence( tenant_id=self.tenant_id, entity_id=int(entity_id) if entity_id is not None else None, relation_id=int(relation_id) if relation_id is not None else None, evidence_type=str(evidence_type or "snapshot").strip()[:40], content_hash=digest, content_excerpt=clean[:4000] if clean else "(empty)", source_type=str(source_type or "system").strip()[:32], source_ref=(str(source_ref).strip()[:255] if source_ref else None), meta_payload=metadata or {}, occurred_at=_utc_now_naive(), ) self.db.add(row) await self.db.flush() return row async def enqueue_ingest_event( self, *, channel: str, event_key: str, payload: Optional[dict[str, Any]] = None, status: str = "pending", ) -> IngestEvent: row = ( await self.db.execute( select(IngestEvent).where( and_( IngestEvent.tenant_id == self.tenant_id, IngestEvent.channel == str(channel).strip()[:32], IngestEvent.event_key == str(event_key).strip()[:128], ) ) ) ).scalar_one_or_none() if row is None: row = IngestEvent( tenant_id=self.tenant_id, channel=str(channel).strip()[:32], event_key=str(event_key).strip()[:128], status=str(status or "pending").strip()[:20], payload=payload or {}, ) self.db.add(row) else: row.status = str(status or row.status or "pending").strip()[:20] row.payload = payload or row.payload or {} row.error_message = None await self.db.flush() return row async def set_ingest_offset( self, *, channel: str, offset_key: str, offset_value: str, payload: Optional[dict[str, Any]] = None, ) -> IngestOffset: row = ( await self.db.execute( select(IngestOffset).where( and_( IngestOffset.tenant_id == self.tenant_id, IngestOffset.channel == str(channel).strip()[:32], IngestOffset.offset_key == str(offset_key).strip()[:128], ) ) ) ).scalar_one_or_none() if row is None: row = IngestOffset( tenant_id=self.tenant_id, channel=str(channel).strip()[:32], offset_key=str(offset_key).strip()[:128], offset_value=str(offset_value or "").strip()[:255], payload=payload or {}, ) self.db.add(row) else: row.offset_value = str(offset_value or row.offset_value).strip()[:255] row.payload = payload or row.payload or {} await self.db.flush() return row async def get_ingest_offset(self, *, channel: str, offset_key: str) -> Optional[str]: row = ( await self.db.execute( select(IngestOffset).where( and_( IngestOffset.tenant_id == self.tenant_id, IngestOffset.channel == str(channel).strip()[:32], IngestOffset.offset_key == str(offset_key).strip()[:128], ) ) ) ).scalar_one_or_none() if row is None: return None return str(row.offset_value or "").strip() or None @staticmethod def _parse_iso_dt(value: Optional[str]) -> Optional[datetime]: raw = str(value or "").strip() if not raw: return None try: dt = datetime.fromisoformat(raw) return dt.replace(tzinfo=None) if dt.tzinfo else dt except Exception: return None async def _open_sync_run( self, *, run_type: str, trigger_source: str, input_payload: Optional[dict[str, Any]] = None, ) -> KGSyncRun: now = _utc_now_naive() row = KGSyncRun( tenant_id=self.tenant_id, run_type=str(run_type or "incremental").strip()[:40], trigger_source=str(trigger_source or "system").strip()[:40], status="running", started_at=now, input_payload=input_payload or {}, result_payload={}, ) self.db.add(row) await self.db.flush() await self.db.commit() await self.db.refresh(row) return row async def _close_sync_run( self, *, row: Optional[KGSyncRun], status: str, result_payload: Optional[dict[str, Any]] = None, error_message: Optional[str] = None, started_at_hint: Optional[datetime] = None, ) -> None: if row is None: return now = _utc_now_naive() started = started_at_hint or row.started_at or now row.status = str(status or "unknown").strip()[:20] row.ended_at = now row.duration_ms = max(int((now - started).total_seconds() * 1000), 0) row.result_payload = result_payload or {} row.error_message = (str(error_message).strip()[:2000] if error_message else None) await self.db.commit() async def list_sync_runs( self, *, limit: int = 50, run_type: Optional[str] = None, status: Optional[str] = None, ) -> dict[str, Any]: max_rows = max(1, min(int(limit), 200)) stmt = select(KGSyncRun).where(KGSyncRun.tenant_id == self.tenant_id) if run_type: stmt = stmt.where(KGSyncRun.run_type == str(run_type).strip()[:40]) if status: stmt = stmt.where(KGSyncRun.status == str(status).strip()[:20]) rows = ( await self.db.execute(stmt.order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()).limit(max_rows)) ).scalars().all() items = [] for row in rows: items.append( { "id": int(row.id), "run_type": str(row.run_type or ""), "trigger_source": str(row.trigger_source or ""), "status": str(row.status or ""), "started_at": row.started_at.isoformat() if row.started_at else None, "ended_at": row.ended_at.isoformat() if row.ended_at else None, "duration_ms": int(row.duration_ms or 0), "input_payload": row.input_payload or {}, "result_payload": row.result_payload or {}, "error_message": str(row.error_message or ""), } ) return { "tenant_id": self.tenant_id, "count": len(items), "items": items, } async def get_sync_slo(self, *, lookback_hours: int = 24) -> dict[str, Any]: hours = max(1, min(int(lookback_hours), 24 * 30)) now = _utc_now_naive() since = now - timedelta(hours=hours) runs = ( await self.db.execute( select(KGSyncRun).where( and_( KGSyncRun.tenant_id == self.tenant_id, KGSyncRun.started_at >= since, KGSyncRun.run_type == "incremental", ) ) ) ).scalars().all() total = len(runs) success = sum(1 for x in runs if str(x.status or "") == "success") partial = sum(1 for x in runs if str(x.status or "") == "partial") failed = sum(1 for x in runs if str(x.status or "") == "failed") avg_duration_ms = ( int(sum(int(x.duration_ms or 0) for x in runs) / total) if total > 0 else 0 ) latest_success = ( await self.db.execute( select(KGSyncRun) .where( and_( KGSyncRun.tenant_id == self.tenant_id, KGSyncRun.run_type == "incremental", KGSyncRun.status == "success", ) ) .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) .limit(1) ) ).scalar_one_or_none() dead_letter = ( await self.db.execute( select(func.count(IngestEvent.id)).where( and_( IngestEvent.tenant_id == self.tenant_id, IngestEvent.status == "dead_letter", ) ) ) ).scalar_one() pending_events = ( await self.db.execute( select(func.count(IngestEvent.id)).where( and_( IngestEvent.tenant_id == self.tenant_id, IngestEvent.status.in_(["pending", "retry"]), ) ) ) ).scalar_one() async def _freshness_minutes(channel: str, offset_key: str) -> Optional[int]: raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) dt = self._parse_iso_dt(raw) if dt is None: return None return max(int((now - dt).total_seconds() // 60), 0) return { "tenant_id": self.tenant_id, "lookback_hours": hours, "window_started_at": since.isoformat(), "window_ended_at": now.isoformat(), "runs_total": total, "runs_success": success, "runs_partial": partial, "runs_failed": failed, "success_rate": round((success / total), 4) if total > 0 else 0.0, "avg_duration_ms": avg_duration_ms, "latest_success_at": latest_success.started_at.isoformat() if latest_success and latest_success.started_at else None, "dead_letter_events": int(dead_letter or 0), "pending_events": int(pending_events or 0), "freshness_minutes": { "db_structured": await _freshness_minutes("db_delta", "last_structured_sync_at"), "feishu_delta": await _freshness_minutes("feishu_delta", "last_feishu_item_updated_at"), "code_scan": await _freshness_minutes("code_scan", "last_code_scan_at"), "symbol_scan": await _freshness_minutes("symbol_scan", "last_symbol_scan_at"), "feature_catalog": await _freshness_minutes("feature_catalog", "last_feature_catalog_sync_at"), }, } async def sync_structured_delta(self, *, limit: int = 1000) -> dict[str, Any]: max_rows = max(1, min(int(limit), 5000)) last_raw = await self.get_ingest_offset(channel="db_delta", offset_key="last_structured_sync_at") last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) counters = { "projects": 0, "tasks": 0, "signals": 0, "users": 0, "relations": 0, "evidences": 0, "from_offset": last_dt.isoformat(), } newest_seen = last_dt project_rows = ( await self.db.execute( select(Project) .where( and_( Project.tenant_id == self.tenant_id, Project.updated_at >= last_dt, ) ) .order_by(Project.updated_at.asc(), Project.id.asc()) .limit(max_rows) ) ).scalars().all() project_map: dict[int, int] = {} for p in project_rows: entity = await self.upsert_entity( entity_type="project", entity_key=f"project:{int(p.id)}", display_name=str(p.title or p.name or f"Project#{int(p.id)}"), payload={ "project_id": int(p.id), "status": str(p.status or "unknown"), "owner_id": int(p.owner_id) if p.owner_id is not None else None, }, source_type="db", source_ref=f"projects:{int(p.id)}", ) project_map[int(p.id)] = int(entity.id) counters["projects"] += 1 if p.updated_at and p.updated_at > newest_seen: newest_seen = p.updated_at user_rows = ( await self.db.execute( select(User) .where( and_( User.tenant_id == self.tenant_id, User.updated_at >= last_dt, ) ) .order_by(User.updated_at.asc(), User.id.asc()) .limit(max_rows) ) ).scalars().all() user_map: dict[int, int] = {} for u in user_rows: entity = await self.upsert_entity( entity_type="user", entity_key=f"user:{int(u.id)}", display_name=str(u.name or f"User#{int(u.id)}"), payload={ "user_id": int(u.id), "role": str(u.role or "unknown"), "is_active": bool(u.is_active), }, source_type="db", source_ref=f"users:{int(u.id)}", ) user_map[int(u.id)] = int(entity.id) counters["users"] += 1 if u.updated_at and u.updated_at > newest_seen: newest_seen = u.updated_at task_rows = ( await self.db.execute( select(Task) .where( and_( Task.tenant_id == self.tenant_id, Task.updated_at >= last_dt, ) ) .order_by(Task.updated_at.asc(), Task.id.asc()) .limit(max_rows) ) ).scalars().all() for t in task_rows: task_entity = await self.upsert_entity( entity_type="task", entity_key=f"task:{int(t.id)}", display_name=str(t.title or f"Task#{int(t.id)}"), payload={ "task_id": int(t.id), "project_id": int(t.project_id) if t.project_id is not None else None, "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, "status": str(t.status or "unknown"), }, source_type="db", source_ref=f"tasks:{int(t.id)}", ) counters["tasks"] += 1 if t.updated_at and t.updated_at > newest_seen: newest_seen = t.updated_at if t.project_id is not None: target_pid = int(t.project_id) if target_pid not in project_map: p = ( await self.db.execute( select(Project).where( and_( Project.tenant_id == self.tenant_id, Project.id == target_pid, ) ) ) ).scalar_one_or_none() if p is not None: p_entity = await self.upsert_entity( entity_type="project", entity_key=f"project:{int(p.id)}", display_name=str(p.title or p.name or f"Project#{int(p.id)}"), payload={ "project_id": int(p.id), "status": str(p.status or "unknown"), "owner_id": int(p.owner_id) if p.owner_id is not None else None, }, source_type="db", source_ref=f"projects:{int(p.id)}", ) project_map[int(p.id)] = int(p_entity.id) if target_pid in project_map: rel = await self.upsert_relation( relation_type="belongs_to", subject_entity_id=int(task_entity.id), object_entity_id=int(project_map[target_pid]), source_type="db", source_ref=f"tasks:{int(t.id)}", ) counters["relations"] += 1 await self.add_evidence( evidence_type="db_delta", content_excerpt=f"task#{int(t.id)} belongs_to project#{target_pid}", relation_id=int(rel.id), source_type="db", source_ref=f"tasks:{int(t.id)}", metadata={"table": "tasks"}, ) counters["evidences"] += 1 if t.assignee_id is not None: uid = int(t.assignee_id) if uid not in user_map: u = ( await self.db.execute( select(User).where(and_(User.tenant_id == self.tenant_id, User.id == uid)) ) ).scalar_one_or_none() if u is not None: u_entity = await self.upsert_entity( entity_type="user", entity_key=f"user:{uid}", display_name=str(u.name or f"User#{uid}"), payload={ "user_id": uid, "role": str(u.role or "unknown"), "is_active": bool(u.is_active), }, source_type="db", source_ref=f"users:{uid}", ) user_map[uid] = int(u_entity.id) if uid in user_map: rel = await self.upsert_relation( relation_type="assigned_to", subject_entity_id=int(task_entity.id), object_entity_id=int(user_map[uid]), source_type="db", source_ref=f"tasks:{int(t.id)}", ) counters["relations"] += 1 await self.add_evidence( evidence_type="db_delta", content_excerpt=f"task#{int(t.id)} assigned_to user#{uid}", relation_id=int(rel.id), source_type="db", source_ref=f"tasks:{int(t.id)}", metadata={"table": "tasks"}, ) counters["evidences"] += 1 signal_rows = ( await self.db.execute( select(Signal) .where( and_( Signal.tenant_id == self.tenant_id, Signal.updated_at >= last_dt, ) ) .order_by(Signal.updated_at.asc(), Signal.id.asc()) .limit(max_rows) ) ).scalars().all() for s in signal_rows: signal_entity = await self.upsert_entity( entity_type="signal", entity_key=f"signal:{int(s.id)}", display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], payload={ "signal_id": int(s.id), "priority": str(s.priority or ""), "status": str(s.status or ""), "matched_project_id": ( int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None ), }, source_type="db", source_ref=f"signals:{int(s.id)}", ) counters["signals"] += 1 if s.updated_at and s.updated_at > newest_seen: newest_seen = s.updated_at pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None if pid is not None: if pid not in project_map: p = ( await self.db.execute( select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == pid)) ) ).scalar_one_or_none() if p is not None: p_entity = await self.upsert_entity( entity_type="project", entity_key=f"project:{pid}", display_name=str(p.title or p.name or f"Project#{pid}"), payload={ "project_id": pid, "status": str(p.status or "unknown"), "owner_id": int(p.owner_id) if p.owner_id is not None else None, }, source_type="db", source_ref=f"projects:{pid}", ) project_map[pid] = int(p_entity.id) if pid in project_map: rel = await self.upsert_relation( relation_type="matches_project", subject_entity_id=int(signal_entity.id), object_entity_id=int(project_map[pid]), source_type="db", source_ref=f"signals:{int(s.id)}", ) counters["relations"] += 1 await self.add_evidence( evidence_type="db_delta", content_excerpt=f"signal#{int(s.id)} matches project#{pid}", relation_id=int(rel.id), source_type="db", source_ref=f"signals:{int(s.id)}", metadata={"table": "signals"}, ) counters["evidences"] += 1 await self.enqueue_ingest_event( channel="db_delta", event_key=f"db_delta:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=counters, status="done", ) await self.set_ingest_offset( channel="db_delta", offset_key="last_structured_sync_at", offset_value=newest_seen.isoformat(), payload={"counters": counters}, ) await self.db.commit() counters["to_offset"] = newest_seen.isoformat() return counters async def enqueue_feishu_delta_events(self, *, limit: int = 500) -> dict[str, Any]: max_rows = max(1, min(int(limit), 2000)) last_raw = await self.get_ingest_offset(channel="feishu_delta", offset_key="last_feishu_item_updated_at") last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) rows = ( await self.db.execute( select(FeishuItem) .where( and_( FeishuItem.tenant_id == self.tenant_id, FeishuItem.updated_at >= last_dt, ) ) .order_by(FeishuItem.updated_at.asc(), FeishuItem.id.asc()) .limit(max_rows) ) ).scalars().all() enqueued = 0 newest_seen = last_dt for item in rows: event_key = str(item.feishu_message_id or f"feishu_item:{int(item.id)}") await self.enqueue_ingest_event( channel="feishu_delta", event_key=event_key[:128], payload={ "feishu_item_id": int(item.id), "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, "level": str(item.level or ""), "route_status": str(item.route_status or ""), }, status="pending", ) enqueued += 1 if item.updated_at and item.updated_at > newest_seen: newest_seen = item.updated_at await self.set_ingest_offset( channel="feishu_delta", offset_key="last_feishu_item_updated_at", offset_value=newest_seen.isoformat(), payload={"batch_size": len(rows)}, ) await self.db.commit() return { "enqueued": enqueued, "from_offset": last_dt.isoformat(), "to_offset": newest_seen.isoformat(), "candidate_count": len(rows), } async def process_pending_events(self, *, max_batch: int = 200, max_retry: int = 3) -> dict[str, Any]: batch = max(1, min(int(max_batch), 1000)) retries = max(0, min(int(max_retry), 10)) rows = ( await self.db.execute( select(IngestEvent) .where( and_( IngestEvent.tenant_id == self.tenant_id, IngestEvent.status.in_(["pending", "retry"]), ) ) .order_by(IngestEvent.created_at.asc(), IngestEvent.id.asc()) .limit(batch) ) ).scalars().all() stats = {"processed": 0, "done": 0, "retry": 0, "dead_letter": 0} for ev in rows: stats["processed"] += 1 try: if str(ev.channel) == "feishu_delta": await self._process_feishu_event(ev) ev.status = "done" ev.error_message = None ev.processed_at = _utc_now_naive() stats["done"] += 1 except Exception as exc: ev.retry_count = int(ev.retry_count or 0) + 1 ev.error_message = str(exc)[:1000] if ev.retry_count > retries: ev.status = "dead_letter" ev.processed_at = _utc_now_naive() stats["dead_letter"] += 1 else: ev.status = "retry" stats["retry"] += 1 await self.db.commit() return stats async def _process_feishu_event(self, event: IngestEvent) -> None: payload = event.payload if isinstance(event.payload, dict) else {} item_id = payload.get("feishu_item_id") if item_id is None: return item = ( await self.db.execute( select(FeishuItem).where( and_( FeishuItem.tenant_id == self.tenant_id, FeishuItem.id == int(item_id), ) ) ) ).scalar_one_or_none() if item is None: return signal_entity = await self.upsert_entity( entity_type="signal", entity_key=f"feishu_item:{int(item.id)}", display_name=str(item.raw_message or f"FeishuItem#{int(item.id)}")[:255], payload={ "feishu_item_id": int(item.id), "level": str(item.level or ""), "route_status": str(item.route_status or ""), "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, }, source_type="feishu", source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], ) await self.add_evidence( evidence_type="feishu_delta", content_excerpt=str(item.raw_message or "")[:1000] or f"feishu_item#{int(item.id)}", entity_id=int(signal_entity.id), source_type="feishu", source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], metadata={"feishu_item_id": int(item.id)}, ) if item.matched_project_id is not None: project_entity = await self.upsert_entity( entity_type="project", entity_key=f"project:{int(item.matched_project_id)}", display_name=f"Project#{int(item.matched_project_id)}", payload={"project_id": int(item.matched_project_id)}, source_type="db", source_ref=f"projects:{int(item.matched_project_id)}", ) rel = await self.upsert_relation( relation_type="matches_project", subject_entity_id=int(signal_entity.id), object_entity_id=int(project_entity.id), source_type="feishu", source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], ) await self.add_evidence( evidence_type="feishu_delta", content_excerpt=f"feishu_item#{int(item.id)} matches project#{int(item.matched_project_id)}", relation_id=int(rel.id), source_type="feishu", source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], metadata={"feishu_item_id": int(item.id)}, ) async def sync_codebase_if_due( self, *, base_dir: Optional[str] = None, interval_hours: int = 6, ) -> dict[str, Any]: hours = max(1, int(interval_hours)) offset_raw = await self.get_ingest_offset(channel="code_scan", offset_key="last_code_scan_at") last_scan_at = self._parse_iso_dt(offset_raw) now = _utc_now_naive() if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): return { "executed": False, "reason": "not_due", "last_scan_at": last_scan_at.isoformat(), "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), } payload = await self.sync_codebase_snapshot(base_dir=base_dir) return { "executed": True, "reason": "due", "payload": payload, } async def sync_symbol_index_if_due( self, *, base_dir: Optional[str] = None, interval_hours: int = 6, max_files: int = 500, ) -> dict[str, Any]: hours = max(1, int(interval_hours)) offset_raw = await self.get_ingest_offset(channel="symbol_scan", offset_key="last_symbol_scan_at") last_scan_at = self._parse_iso_dt(offset_raw) now = _utc_now_naive() if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): return { "executed": False, "reason": "not_due", "last_scan_at": last_scan_at.isoformat(), "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), } symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) payload = await symbol_service.sync_codebase_symbols( base_dir=base_dir, max_files=max_files, force_reindex=False, ) symbol_graph = await self._sync_symbol_entities(limit=800) payload["kg_symbol_sync"] = symbol_graph await self.enqueue_ingest_event( channel="symbol_scan", event_key=f"symbol_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=payload, status="done", ) await self.set_ingest_offset( channel="symbol_scan", offset_key="last_symbol_scan_at", offset_value=_utc_now_naive().isoformat(), payload=payload, ) await self.db.commit() return { "executed": True, "reason": "due", "payload": payload, } async def _sync_symbol_entities(self, *, limit: int = 800) -> dict[str, Any]: max_rows = max(10, min(int(limit), 3000)) defs = ( await self.db.execute( select(SymbolDefinition) .where(SymbolDefinition.tenant_id == self.tenant_id) .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) .limit(max_rows) ) ).scalars().all() if not defs: return {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} counters = {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} module_entity_map: dict[str, int] = {} symbol_entity_map: dict[int, int] = {} name_entity_map: dict[str, int] = {} qn_entity_map: dict[str, int] = {} for row in defs: qn = str(row.qualified_name or row.symbol_name or f"symbol#{int(row.id)}") symbol_entity = await self.upsert_entity( entity_type="symbol", entity_key=f"symbol:{int(row.id)}", display_name=qn[:255], payload={ "symbol_id": int(row.id), "symbol_name": str(row.symbol_name or ""), "qualified_name": str(row.qualified_name or ""), "symbol_type": str(row.symbol_type or ""), "language": str(row.language or ""), "file_path": str(row.file_path or ""), "start_line": int(row.start_line) if row.start_line is not None else None, "end_line": int(row.end_line) if row.end_line is not None else None, }, source_type="symbol_index", source_ref=str(row.file_path or "")[:255], ) counters["symbols"] += 1 symbol_entity_map[int(row.id)] = int(symbol_entity.id) if str(row.symbol_name or "").strip(): name_entity_map[str(row.symbol_name).strip()] = int(symbol_entity.id) if str(row.qualified_name or "").strip(): qn_entity_map[str(row.qualified_name).strip()] = int(symbol_entity.id) file_key = str(row.file_path or "").strip() if not file_key: continue module_entity_id = module_entity_map.get(file_key) if module_entity_id is None: module_entity = await self.upsert_entity( entity_type="module", entity_key=f"module:{file_key}", display_name=file_key[:255], payload={"file_path": file_key}, source_type="symbol_index", source_ref=file_key[:255], ) module_entity_id = int(module_entity.id) module_entity_map[file_key] = module_entity_id counters["modules"] += 1 rel = await self.upsert_relation( relation_type="defined_in", subject_entity_id=int(symbol_entity.id), object_entity_id=int(module_entity_id), source_type="symbol_index", source_ref=file_key[:255], payload={ "symbol_id": int(row.id), "symbol_type": str(row.symbol_type or ""), "language": str(row.language or ""), }, ) counters["relations"] += 1 await self.add_evidence( evidence_type="symbol_index", content_excerpt=( f"{str(row.symbol_name or '')} defined_in {file_key}:" f"{int(row.start_line) if row.start_line is not None else 0}" ), relation_id=int(rel.id), source_type="symbol_index", source_ref=file_key[:255], metadata={"symbol_id": int(row.id), "file_path": file_key}, ) counters["evidences"] += 1 refs = ( await self.db.execute( select(SymbolReference) .where(SymbolReference.tenant_id == self.tenant_id) .order_by(SymbolReference.updated_at.desc(), SymbolReference.id.desc()) .limit(max_rows * 3) ) ).scalars().all() for row in refs: source_entity_id = None if row.source_symbol_id is not None: source_entity_id = symbol_entity_map.get(int(row.source_symbol_id)) if source_entity_id is None: continue target_entity_id = None target_qn = str(row.target_qualified_name or "").strip() target_name = str(row.target_symbol_name or "").strip() if target_qn: target_entity_id = qn_entity_map.get(target_qn) if target_entity_id is None and target_name: target_entity_id = name_entity_map.get(target_name) if target_entity_id is None: continue rel_type = str(row.relation_type or "references").strip() rel = await self.upsert_relation( relation_type=rel_type[:40], subject_entity_id=int(source_entity_id), object_entity_id=int(target_entity_id), source_type="symbol_index", source_ref=str(row.file_path or "")[:255], payload={ "symbol_reference_id": int(row.id), "target_symbol_name": target_name, "target_qualified_name": target_qn, }, ) counters["relations"] += 1 await self.add_evidence( evidence_type="symbol_reference", content_excerpt=f"{int(source_entity_id)} {rel_type} {target_name or target_qn}", relation_id=int(rel.id), source_type="symbol_index", source_ref=str(row.file_path or "")[:255], metadata={ "symbol_reference_id": int(row.id), "target_symbol_name": target_name, "target_qualified_name": target_qn, }, ) counters["evidences"] += 1 return counters @staticmethod def _routing_rule_candidate_paths() -> list[Path]: candidates: list[Path] = [] configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() if configured: candidates.append(Path(configured).expanduser()) backend_root = Path(__file__).resolve().parents[2] project_root = Path(__file__).resolve().parents[3] candidates.extend( [ project_root / ".openclaw" / "pmflow-routing-rules.json", backend_root / ".openclaw" / "pmflow-routing-rules.json", ] ) deduped: list[Path] = [] seen: set[str] = set() for path in candidates: key = str(path) if key in seen: continue seen.add(key) deduped.append(path) return deduped @classmethod def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]: specs: dict[str, dict[str, str]] = { "low": { "tier": "low", "provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(), "model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(), "api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(), "source": "env_default", "source_path": "", "rule_name": "", }, "high": { "tier": "high", "provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(), "model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(), "api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(), "source": "env_default", "source_path": "", "rule_name": "", }, } for path in cls._routing_rule_candidate_paths(): try: content = path.read_text(encoding="utf-8") payload = json.loads(content) except Exception: continue rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else [] if not rows: continue enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))] enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True) resolved_any = False for tier in ("low", "high"): for row in enabled_rows: if str(row.get("tier") or "").strip().lower() != tier: continue model = str(row.get("model") or "").strip() if not model: continue specs[tier] = { "tier": tier, "provider": str(row.get("provider") or specs[tier]["provider"]).strip(), "model": model, "api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(), "source": "routing_rule", "source_path": str(path), "rule_name": str(row.get("name") or "").strip(), } resolved_any = True break if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")): break return specs async def _sync_model_guardrail_entities(self) -> dict[str, Any]: counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0} model_specs = self._resolve_guardrail_target_models() policy_entity = await self.upsert_entity( entity_type="governance_policy", entity_key=DDL_GUARDRAIL_POLICY_KEY, display_name=DDL_GUARDRAIL_POLICY_NAME, payload={ "policy_code": "ddl_via_interface_only", "enforcement_level": "hard_block", "blocked_sql_prefixes": DDL_BLOCKED_PREFIXES, "required_execution_path": "api_or_migration_interface", "enforced_by": [ "backend/app/database.py", "backend/app/services/llm.py", "backend/app/config/ai_prompts.py", ], }, source_type="governance", source_ref="backend/app/database.py", ) counters["policies"] += 1 await self.add_evidence( evidence_type="policy_guardrail", content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.", entity_id=int(policy_entity.id), source_type="governance", source_ref="backend/app/database.py", metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES}, ) counters["evidences"] += 1 interface_specs = [ { "entity_key": "interface:api:/ai-steward/command/dispatch", "display_name": "POST /ai-steward/command/dispatch", "payload": {"path": "/ai-steward/command/dispatch", "kind": "api"}, "source_ref": "backend/app/api/ai_steward.py", }, { "entity_key": "interface:migration:alembic", "display_name": "Alembic Migration Interface", "payload": {"path": "alembic", "kind": "migration"}, "source_ref": "backend/alembic/versions", }, ] interface_ids: list[int] = [] for item in interface_specs: interface_entity = await self.upsert_entity( entity_type="interface", entity_key=str(item["entity_key"]), display_name=str(item["display_name"]), payload=dict(item["payload"]), source_type="governance", source_ref=str(item["source_ref"]), ) counters["interfaces"] += 1 interface_ids.append(int(interface_entity.id)) rel = await self.upsert_relation( relation_type="policy_enforced_by_interface", subject_entity_id=int(policy_entity.id), object_entity_id=int(interface_entity.id), source_type="governance", source_ref=str(item["source_ref"]), ) counters["relations"] += 1 await self.add_evidence( evidence_type="policy_guardrail", content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}", relation_id=int(rel.id), source_type="governance", source_ref=str(item["source_ref"]), metadata=dict(item["payload"]), ) counters["evidences"] += 1 for tier in ("low", "high"): spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {} model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5") provider = str(spec.get("provider") or "").strip() api_env = str(spec.get("api_env") or "").strip() source = str(spec.get("source") or "env_default").strip() source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255] entity = await self.upsert_entity( entity_type="ai_model", entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}", display_name=model_name[:255], payload={ "tier": tier, "provider": provider, "model": model_name, "api_env": api_env, "source": source, "routing_rule": str(spec.get("rule_name") or "").strip(), }, source_type="model_routing", source_ref=source_ref or None, ) counters["models"] += 1 rel = await self.upsert_relation( relation_type="model_must_follow_policy", subject_entity_id=int(entity.id), object_entity_id=int(policy_entity.id), source_type="governance", source_ref=source_ref or None, payload={"tier": tier, "policy_code": "ddl_via_interface_only"}, ) counters["relations"] += 1 await self.add_evidence( evidence_type="policy_guardrail", content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}", relation_id=int(rel.id), source_type="governance", source_ref=source_ref or None, metadata={"tier": tier, "provider": provider, "model": model_name}, ) counters["evidences"] += 1 for interface_id in interface_ids: rel = await self.upsert_relation( relation_type="model_executes_via_interface", subject_entity_id=int(entity.id), object_entity_id=int(interface_id), source_type="governance", source_ref=source_ref or None, payload={"tier": tier}, ) counters["relations"] += 1 await self.add_evidence( evidence_type="policy_guardrail", content_excerpt=f"{tier} model {model_name} executes via controlled interface", relation_id=int(rel.id), source_type="governance", source_ref=source_ref or None, metadata={"tier": tier}, ) counters["evidences"] += 1 return counters async def sync_model_guardrails_if_due( self, *, interval_hours: int = 6, ) -> dict[str, Any]: hours = max(1, int(interval_hours)) offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at") last_sync_at = self._parse_iso_dt(offset_raw) now = _utc_now_naive() if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): return { "executed": False, "reason": "not_due", "last_sync_at": last_sync_at.isoformat(), "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), } payload = await self._sync_model_guardrail_entities() await self.enqueue_ingest_event( channel="model_guardrail", event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=payload, status="done", ) await self.set_ingest_offset( channel="model_guardrail", offset_key="last_model_guardrail_sync_at", offset_value=_utc_now_naive().isoformat(), payload=payload, ) await self.db.commit() return { "executed": True, "reason": "due", "payload": payload, } async def sync_model_guardrails_now(self) -> dict[str, Any]: payload = await self._sync_model_guardrail_entities() await self.enqueue_ingest_event( channel="model_guardrail", event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=payload, status="done", ) await self.set_ingest_offset( channel="model_guardrail", offset_key="last_model_guardrail_sync_at", offset_value=_utc_now_naive().isoformat(), payload=payload, ) await self.db.commit() return payload async def sync_feature_catalog_if_due( self, *, interval_hours: int = 6, ) -> dict[str, Any]: hours = max(1, int(interval_hours)) offset_raw = await self.get_ingest_offset(channel="feature_catalog", offset_key="last_feature_catalog_sync_at") last_sync_at = self._parse_iso_dt(offset_raw) now = _utc_now_naive() if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): return { "executed": False, "reason": "not_due", "last_sync_at": last_sync_at.isoformat(), "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), } payload = await self._sync_feature_catalog_entities(limit=300) await self.enqueue_ingest_event( channel="feature_catalog", event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=payload, status="done", ) await self.set_ingest_offset( channel="feature_catalog", offset_key="last_feature_catalog_sync_at", offset_value=_utc_now_naive().isoformat(), payload=payload, ) await self.db.commit() return { "executed": True, "reason": "due", "payload": payload, } async def sync_feature_catalog_now(self) -> dict[str, Any]: payload = await self._sync_feature_catalog_entities(limit=300) await self.enqueue_ingest_event( channel="feature_catalog", event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=payload, status="done", ) await self.set_ingest_offset( channel="feature_catalog", offset_key="last_feature_catalog_sync_at", offset_value=_utc_now_naive().isoformat(), payload=payload, ) await self.db.commit() return payload async def _sync_feature_catalog_entities(self, *, limit: int = 300) -> dict[str, Any]: snapshot = await context_cache_service.get_system_snapshot( db=self.db, tenant_id=self.tenant_id, ) platform = snapshot.get("platform_profile") if isinstance(snapshot.get("platform_profile"), dict) else {} catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] rows = [row for row in catalog if isinstance(row, dict)][: max(1, min(int(limit), 1000))] if not rows: return {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} counters = {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} route_entity_map: dict[str, int] = {} page_entity_map: dict[str, int] = {} api_entity_map: dict[str, int] = {} for row in rows: name = str(row.get("name") or "").strip() or "unknown_feature" route = str(row.get("route") or "").strip() summary = str(row.get("summary") or "").strip() page_file = str(row.get("page_file") or "").strip() page_component = str(row.get("page_component") or "").strip() apis = row.get("apis") if isinstance(row.get("apis"), list) else [] aliases = row.get("aliases") if isinstance(row.get("aliases"), list) else [] feature_slug = (route or name).strip().lower()[:200] feature_entity = await self.upsert_entity( entity_type="feature", entity_key=f"feature:{feature_slug}", display_name=name[:255], payload={ "name": name, "route": route, "summary": summary, "page_file": page_file, "page_component": page_component, "apis": [str(x) for x in apis[:20] if str(x).strip()], "aliases": [str(x) for x in aliases[:20] if str(x).strip()], }, source_type="feature_catalog", source_ref=(route or page_file or name)[:255], ) counters["features"] += 1 await self.add_evidence( evidence_type="feature_catalog", content_excerpt=f"{name} | route={route or 'n/a'} | page={page_file or 'n/a'}", entity_id=int(feature_entity.id), source_type="feature_catalog", source_ref=(route or page_file or name)[:255], metadata={"feature_name": name, "route": route, "page_file": page_file}, ) counters["evidences"] += 1 route_entity_id: Optional[int] = None if route: if route not in route_entity_map: route_entity = await self.upsert_entity( entity_type="route", entity_key=f"route:{route[:200]}", display_name=route[:255], payload={"route": route}, source_type="feature_catalog", source_ref=route[:255], ) route_entity_map[route] = int(route_entity.id) counters["routes"] += 1 route_entity_id = route_entity_map[route] rel = await self.upsert_relation( relation_type="feature_has_route", subject_entity_id=int(feature_entity.id), object_entity_id=int(route_entity_id), source_type="feature_catalog", source_ref=route[:255], ) counters["relations"] += 1 await self.add_evidence( evidence_type="feature_catalog", content_excerpt=f"{name} has route {route}", relation_id=int(rel.id), source_type="feature_catalog", source_ref=route[:255], metadata={"feature_name": name, "route": route}, ) counters["evidences"] += 1 page_entity_id: Optional[int] = None if page_file: if page_file not in page_entity_map: page_entity = await self.upsert_entity( entity_type="page", entity_key=f"page:{page_file[:220]}", display_name=(page_component or page_file)[:255], payload={"page_file": page_file, "page_component": page_component}, source_type="feature_catalog", source_ref=page_file[:255], ) page_entity_map[page_file] = int(page_entity.id) counters["pages"] += 1 page_entity_id = page_entity_map[page_file] rel = await self.upsert_relation( relation_type="feature_rendered_by", subject_entity_id=int(feature_entity.id), object_entity_id=int(page_entity_id), source_type="feature_catalog", source_ref=page_file[:255], ) counters["relations"] += 1 await self.add_evidence( evidence_type="feature_catalog", content_excerpt=f"{name} rendered_by {page_file}", relation_id=int(rel.id), source_type="feature_catalog", source_ref=page_file[:255], metadata={"feature_name": name, "page_file": page_file}, ) counters["evidences"] += 1 if route_entity_id is not None and page_entity_id is not None: rel = await self.upsert_relation( relation_type="route_to_page", subject_entity_id=int(route_entity_id), object_entity_id=int(page_entity_id), source_type="feature_catalog", source_ref=(route or page_file)[:255], ) counters["relations"] += 1 await self.add_evidence( evidence_type="feature_catalog", content_excerpt=f"{route} maps_to {page_file}", relation_id=int(rel.id), source_type="feature_catalog", source_ref=(route or page_file)[:255], metadata={"route": route, "page_file": page_file}, ) counters["evidences"] += 1 for raw_api in apis[:20]: api_path = str(raw_api or "").strip() if not api_path: continue if api_path not in api_entity_map: api_entity = await self.upsert_entity( entity_type="api_endpoint", entity_key=f"api:{api_path[:220]}", display_name=api_path[:255], payload={"path": api_path}, source_type="feature_catalog", source_ref=api_path[:255], ) api_entity_map[api_path] = int(api_entity.id) counters["apis"] += 1 rel = await self.upsert_relation( relation_type="feature_calls_api", subject_entity_id=int(feature_entity.id), object_entity_id=int(api_entity_map[api_path]), source_type="feature_catalog", source_ref=api_path[:255], ) counters["relations"] += 1 await self.add_evidence( evidence_type="feature_catalog", content_excerpt=f"{name} calls {api_path}", relation_id=int(rel.id), source_type="feature_catalog", source_ref=api_path[:255], metadata={"feature_name": name, "api_path": api_path}, ) counters["evidences"] += 1 return counters async def run_incremental_cycle( self, *, delta_limit: int = 1000, event_batch: int = 200, code_scan_hours: int = 6, feature_scan_hours: int = 6, code_scan_base_dir: Optional[str] = None, run_source: str = "system", ) -> dict[str, Any]: started = _utc_now_naive() sync_run = await self._open_sync_run( run_type="incremental", trigger_source=run_source, input_payload={ "delta_limit": int(delta_limit), "event_batch": int(event_batch), "code_scan_hours": int(code_scan_hours), "feature_scan_hours": int(feature_scan_hours), "code_scan_base_dir": str(code_scan_base_dir or ""), }, ) run_id = int(sync_run.id) run_started_at = sync_run.started_at or started stage_results: dict[str, Any] = {} stage_errors: dict[str, str] = {} success_count = 0 try: try: stage_results["structured_delta"] = await self.sync_structured_delta(limit=delta_limit) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["structured_delta"] = str(exc)[:500] stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]} try: stage_results["feishu_enqueue"] = await self.enqueue_feishu_delta_events(limit=min(delta_limit, 1000)) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["feishu_enqueue"] = str(exc)[:500] stage_results["feishu_enqueue"] = {"error": stage_errors["feishu_enqueue"]} try: stage_results["event_processing"] = await self.process_pending_events(max_batch=event_batch) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["event_processing"] = str(exc)[:500] stage_results["event_processing"] = {"error": stage_errors["event_processing"]} try: stage_results["code_scan"] = await self.sync_codebase_if_due( base_dir=code_scan_base_dir, interval_hours=code_scan_hours, ) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["code_scan"] = str(exc)[:500] stage_results["code_scan"] = {"error": stage_errors["code_scan"]} try: stage_results["symbol_index"] = await self.sync_symbol_index_if_due( base_dir=code_scan_base_dir, interval_hours=code_scan_hours, ) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["symbol_index"] = str(exc)[:500] stage_results["symbol_index"] = {"error": stage_errors["symbol_index"]} try: stage_results["feature_catalog"] = await self.sync_feature_catalog_if_due( interval_hours=feature_scan_hours, ) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["feature_catalog"] = str(exc)[:500] stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]} try: stage_results["model_guardrails"] = await self.sync_model_guardrails_if_due( interval_hours=feature_scan_hours, ) success_count += 1 except Exception as exc: await self.db.rollback() stage_errors["model_guardrails"] = str(exc)[:500] stage_results["model_guardrails"] = {"error": stage_errors["model_guardrails"]} ended = _utc_now_naive() if success_count == 7: final_status = "success" elif success_count == 0: final_status = "failed" else: final_status = "partial" payload = { "tenant_id": self.tenant_id, "started_at": started.isoformat(), "ended_at": ended.isoformat(), "run_id": run_id, "run_status": final_status, "success_stage_count": success_count, "failed_stage_count": len(stage_errors), "stage_errors": stage_errors, **stage_results, } await self._close_sync_run( row=sync_run, status=final_status, result_payload=payload, error_message=("; ".join(f"{k}:{v}" for k, v in stage_errors.items()) if stage_errors else None), started_at_hint=run_started_at, ) return payload except Exception as exc: await self.db.rollback() ended = _utc_now_naive() payload = { "tenant_id": self.tenant_id, "started_at": started.isoformat(), "ended_at": ended.isoformat(), "run_id": run_id, "run_status": "failed", "success_stage_count": success_count, "failed_stage_count": max(1, len(stage_errors)), "stage_errors": stage_errors, **stage_results, } await self._close_sync_run( row=sync_run, status="failed", result_payload=payload, error_message=str(exc)[:1000], started_at_hint=run_started_at, ) return payload async def sync_structured_entities(self, *, limit: int = 1000) -> dict[str, Any]: max_rows = max(1, min(int(limit), 5000)) counters = { "projects": 0, "tasks": 0, "signals": 0, "users": 0, "relations": 0, "evidences": 0, } project_map: dict[int, int] = {} projects = ( await self.db.execute( select(Project) .where(Project.tenant_id == self.tenant_id) .order_by(Project.updated_at.desc(), Project.id.desc()) .limit(max_rows) ) ).scalars().all() for p in projects: entity = await self.upsert_entity( entity_type="project", entity_key=f"project:{int(p.id)}", display_name=str(p.title or p.name or f"Project#{int(p.id)}"), payload={ "project_id": int(p.id), "status": str(p.status or "unknown"), "owner_id": int(p.owner_id) if p.owner_id is not None else None, }, source_type="db", source_ref=f"projects:{int(p.id)}", ) project_map[int(p.id)] = int(entity.id) counters["projects"] += 1 user_map: dict[int, int] = {} users = ( await self.db.execute( select(User) .where(User.tenant_id == self.tenant_id) .order_by(User.updated_at.desc(), User.id.desc()) .limit(max_rows) ) ).scalars().all() for u in users: entity = await self.upsert_entity( entity_type="user", entity_key=f"user:{int(u.id)}", display_name=str(u.name or f"User#{int(u.id)}"), payload={ "user_id": int(u.id), "role": str(u.role or "unknown"), "is_active": bool(u.is_active), }, source_type="db", source_ref=f"users:{int(u.id)}", ) user_map[int(u.id)] = int(entity.id) counters["users"] += 1 tasks = ( await self.db.execute( select(Task) .where(Task.tenant_id == self.tenant_id) .order_by(Task.updated_at.desc(), Task.id.desc()) .limit(max_rows) ) ).scalars().all() for t in tasks: task_entity = await self.upsert_entity( entity_type="task", entity_key=f"task:{int(t.id)}", display_name=str(t.title or f"Task#{int(t.id)}"), payload={ "task_id": int(t.id), "project_id": int(t.project_id) if t.project_id is not None else None, "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, "status": str(t.status or "unknown"), }, source_type="db", source_ref=f"tasks:{int(t.id)}", ) counters["tasks"] += 1 if t.project_id is not None and int(t.project_id) in project_map: rel = await self.upsert_relation( relation_type="belongs_to", subject_entity_id=int(task_entity.id), object_entity_id=int(project_map[int(t.project_id)]), source_type="db", source_ref=f"tasks:{int(t.id)}", ) counters["relations"] += 1 await self.add_evidence( evidence_type="db_row", content_excerpt=f"task#{int(t.id)} belongs_to project#{int(t.project_id)}", relation_id=int(rel.id), source_type="db", source_ref=f"tasks:{int(t.id)}", metadata={"table": "tasks"}, ) counters["evidences"] += 1 if t.assignee_id is not None and int(t.assignee_id) in user_map: rel = await self.upsert_relation( relation_type="assigned_to", subject_entity_id=int(task_entity.id), object_entity_id=int(user_map[int(t.assignee_id)]), source_type="db", source_ref=f"tasks:{int(t.id)}", ) counters["relations"] += 1 await self.add_evidence( evidence_type="db_row", content_excerpt=f"task#{int(t.id)} assigned_to user#{int(t.assignee_id)}", relation_id=int(rel.id), source_type="db", source_ref=f"tasks:{int(t.id)}", metadata={"table": "tasks"}, ) counters["evidences"] += 1 signals = ( await self.db.execute( select(Signal) .where(Signal.tenant_id == self.tenant_id) .order_by(Signal.updated_at.desc(), Signal.id.desc()) .limit(max_rows) ) ).scalars().all() for s in signals: signal_entity = await self.upsert_entity( entity_type="signal", entity_key=f"signal:{int(s.id)}", display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], payload={ "signal_id": int(s.id), "priority": str(s.priority or ""), "status": str(s.status or ""), "matched_project_id": ( int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None ), }, source_type="db", source_ref=f"signals:{int(s.id)}", ) counters["signals"] += 1 target_pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None if target_pid is not None and target_pid in project_map: rel = await self.upsert_relation( relation_type="matches_project", subject_entity_id=int(signal_entity.id), object_entity_id=int(project_map[target_pid]), source_type="db", source_ref=f"signals:{int(s.id)}", ) counters["relations"] += 1 await self.add_evidence( evidence_type="db_row", content_excerpt=f"signal#{int(s.id)} matches project#{target_pid}", relation_id=int(rel.id), source_type="db", source_ref=f"signals:{int(s.id)}", metadata={"table": "signals"}, ) counters["evidences"] += 1 counters["model_guardrails"] = await self._sync_model_guardrail_entities() await self.enqueue_ingest_event( channel="db_delta", event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload={"limit": max_rows, "counters": counters}, status="done", ) await self.set_ingest_offset( channel="db_delta", offset_key="last_structured_sync_at", offset_value=_utc_now_naive().isoformat(), payload={"counters": counters}, ) await self.db.commit() return counters async def sync_codebase_snapshot(self, *, base_dir: Optional[str] = None) -> dict[str, Any]: root = Path(base_dir or "/app/backend").resolve() if not root.exists() or not root.is_dir(): raise ValueError(f"codebase path not found: {root}") py_files = [p for p in root.rglob("*.py") if p.is_file()] route_markers = ("@router.get(", "@router.post(", "@router.put(", "@router.patch(", "@router.delete(") route_count = 0 service_file_count = 0 model_file_count = 0 for py_file in py_files: rel = str(py_file.relative_to(root)) if rel.startswith("app/services/"): service_file_count += 1 if rel.startswith("app/models/"): model_file_count += 1 try: text = py_file.read_text(encoding="utf-8", errors="ignore") except Exception: continue for marker in route_markers: route_count += text.count(marker) payload = { "root": str(root), "python_files": len(py_files), "service_files": service_file_count, "model_files": model_file_count, "api_route_markers": route_count, "scanned_at": _utc_now_naive().isoformat(), } entity = await self.upsert_entity( entity_type="service", entity_key="service:backend_codebase", display_name="Backend Codebase Snapshot", payload=payload, source_type="code_scan", source_ref="filesystem", ) await self.add_evidence( evidence_type="code_scan", content_excerpt=f"python_files={len(py_files)}, routes={route_count}", entity_id=int(entity.id), source_type="code_scan", source_ref=str(root), metadata=payload, ) await self.enqueue_ingest_event( channel="code_scan", event_key=f"code_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", payload=payload, status="done", ) await self.set_ingest_offset( channel="code_scan", offset_key="last_code_scan_at", offset_value=_utc_now_naive().isoformat(), payload=payload, ) await self.db.commit() return payload async def get_overview(self) -> dict[str, Any]: entity_total = ( await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id)) ).scalar_one() relation_total = ( await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) ).scalar_one() evidence_total = ( await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) ).scalar_one() pending_events = ( await self.db.execute( select(func.count(IngestEvent.id)).where( and_( IngestEvent.tenant_id == self.tenant_id, IngestEvent.status.in_(["pending", "retry"]), ) ) ) ).scalar_one() latest_run = ( await self.db.execute( select(KGSyncRun) .where(KGSyncRun.tenant_id == self.tenant_id) .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) .limit(1) ) ).scalar_one_or_none() entity_groups = ( await self.db.execute( select(KGEntity.entity_type, func.count(KGEntity.id)) .where(KGEntity.tenant_id == self.tenant_id) .group_by(KGEntity.entity_type) ) ).all() relation_groups = ( await self.db.execute( select(KGRelation.relation_type, func.count(KGRelation.id)) .where(KGRelation.tenant_id == self.tenant_id) .group_by(KGRelation.relation_type) ) ).all() return { "tenant_id": self.tenant_id, "entity_total": int(entity_total or 0), "relation_total": int(relation_total or 0), "evidence_total": int(evidence_total or 0), "pending_events": int(pending_events or 0), "entities_by_type": {str(k): int(v or 0) for k, v in entity_groups}, "relations_by_type": {str(k): int(v or 0) for k, v in relation_groups}, "latest_sync_run": ( { "id": int(latest_run.id), "run_type": str(latest_run.run_type or ""), "trigger_source": str(latest_run.trigger_source or ""), "status": str(latest_run.status or ""), "started_at": latest_run.started_at.isoformat() if latest_run.started_at else None, "ended_at": latest_run.ended_at.isoformat() if latest_run.ended_at else None, "duration_ms": int(latest_run.duration_ms or 0), } if latest_run is not None else None ), "snapshot_at": _utc_now_naive().isoformat(), }