Files
ATOCore/src/atocore/engineering/mirror.py
Anto01 53b71639ad feat: Phase 5B-5D — 10 canonical engineering queries + triage UI
The graph becomes useful. Before this commit, entities sat in the DB
as data with no narrative. After: the director can ask "what am I
forgetting?" and get a structured answer in milliseconds.

New module (src/atocore/engineering/queries.py, 360 lines):

Structure queries (Q-001/004/005/008/013):
- system_map(project): full subsystem → component tree + orphans +
  materials joined per component
- decisions_affecting(project, subsystem_id?): decisions linked via
  AFFECTED_BY_DECISION, scoped to a subsystem or whole project
- requirements_for(component_id): Q-005 forward trace
- recent_changes(project, since, limit): Q-013 via memory_audit join
  (reuses the Phase 4 audit infrastructure — entity_kind='entity')

The 3 killer queries (the real value):
- orphan_requirements(project): requirements with NO inbound SATISFIES
  edge. "What do I claim the system must do that nothing actually
  claims to handle?" Q-006.
- risky_decisions(project): decisions whose BASED_ON_ASSUMPTION edge
  points to an assumption with status in ('superseded','invalid') OR
  properties.flagged=True. Finds cascading risk from shaky premises. Q-009.
- unsupported_claims(project): ValidationClaim entities with no inbound
  SUPPORTS edge — asserted but no Result to back them. Q-011.
- all_gaps(project): runs all three in one call for dashboards.

History + impact (Q-016/017):
- impact_analysis(entity_id, max_depth=3): BFS over outbound edges.
  "What's downstream of this if I change it?"
- evidence_chain(entity_id): inbound SUPPORTS/EVIDENCED_BY/DESCRIBED_BY/
  VALIDATED_BY/ANALYZED_BY. "How do I know this is true?"

API (src/atocore/api/routes.py) exposes 10 endpoints:
- GET /engineering/projects/{p}/systems
- GET /engineering/decisions?project=&subsystem=
- GET /engineering/components/{id}/requirements
- GET /engineering/changes?project=&since=&limit=
- GET /engineering/gaps/orphan-requirements?project=
- GET /engineering/gaps/risky-decisions?project=
- GET /engineering/gaps/unsupported-claims?project=
- GET /engineering/gaps?project=  (combined)
- GET /engineering/impact?entity=&max_depth=
- GET /engineering/evidence?entity=

Mirror integration (src/atocore/engineering/mirror.py):
- New _gaps_section() renders at top of every project page
- If any gap non-empty: shows up-to-10 per category with names + context
- Clean project: " No gaps detected" — signals everything is traced

Triage UI (src/atocore/engineering/triage_ui.py):
- /admin/triage now shows BOTH memory candidates AND entity candidates
- Entity cards: name, type, project, confidence, source provenance,
  Promote/Reject buttons, link to wiki entity page
- Entity promote/reject via fetch to /entities/{id}/promote|reject
- One triage UI for the whole pipeline — consistent muscle memory

Tests: 326 → 341 (15 new, all in test_engineering_queries.py):
- System map structure + orphan detection + material joins
- Killer queries: positive + negative cases (empty when clean)
- Decisions query: project-wide and subsystem-scoped
- Impact analysis walks outbound BFS
- Evidence chain walks inbound provenance

No regressions. All 10 daily queries from the plan are now live and
answering real questions against the graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 07:18:46 -04:00

329 lines
11 KiB
Python

"""Human Mirror — derived readable project views from structured data.
Layer 3 of the AtoCore architecture. Generates human-readable markdown
pages from the engineering entity graph, Trusted Project State, and
active memories. These pages are DERIVED — they are not canonical
machine truth. They are support surfaces for human inspection and
audit comfort.
The mirror never invents content. Every line traces back to an entity,
a state entry, or a memory. If the structured data is wrong, the
mirror is wrong — fix the source, not the page.
"""
from __future__ import annotations
from atocore.context.project_state import get_state
from atocore.engineering.service import (
get_entities,
get_relationships,
)
from atocore.memory.service import get_memories
from atocore.observability.logger import get_logger
log = get_logger("mirror")
def generate_project_overview(project: str) -> str:
"""Generate a full project overview page in markdown."""
sections = [
_header(project),
_synthesis_section(project),
_gaps_section(project), # Phase 5: killer queries surface here
_state_section(project),
_system_architecture(project),
_decisions_section(project),
_requirements_section(project),
_materials_section(project),
_vendors_section(project),
_active_memories_section(project),
_footer(project),
]
return "\n\n".join(s for s in sections if s)
def _gaps_section(project: str) -> str:
"""Phase 5: surface the 3 killer-query gaps on every project page.
If any gap is non-empty, it appears near the top so the director
sees "what am I forgetting?" before the rest of the report.
"""
try:
from atocore.engineering.queries import all_gaps
result = all_gaps(project)
except Exception:
return ""
orphan = result["orphan_requirements"]["count"]
risky = result["risky_decisions"]["count"]
unsup = result["unsupported_claims"]["count"]
if orphan == 0 and risky == 0 and unsup == 0:
return (
"## Coverage Gaps\n\n"
"> ✅ No gaps detected: every requirement is satisfied, "
"no decisions rest on flagged assumptions, every claim has evidence.\n"
)
lines = ["## Coverage Gaps", ""]
lines.append(
"> ⚠️ Items below need attention — gaps in the engineering graph.\n"
)
if orphan:
lines.append(f"### {orphan} Orphan Requirement(s)")
lines.append("*Requirements with no component claiming to satisfy them:*")
lines.append("")
for r in result["orphan_requirements"]["gaps"][:10]:
lines.append(f"- **{r['name']}** — {(r['description'] or '')[:120]}")
if orphan > 10:
lines.append(f"- _...and {orphan - 10} more_")
lines.append("")
if risky:
lines.append(f"### {risky} Risky Decision(s)")
lines.append("*Decisions based on assumptions that are flagged, superseded, or invalid:*")
lines.append("")
for d in result["risky_decisions"]["gaps"][:10]:
lines.append(
f"- **{d['decision_name']}** — based on flagged assumption "
f"_{d['assumption_name']}_ ({d['assumption_status']})"
)
lines.append("")
if unsup:
lines.append(f"### {unsup} Unsupported Claim(s)")
lines.append("*Validation claims with no supporting Result entity:*")
lines.append("")
for c in result["unsupported_claims"]["gaps"][:10]:
lines.append(f"- **{c['name']}** — {(c['description'] or '')[:120]}")
lines.append("")
return "\n".join(lines)
def _synthesis_section(project: str) -> str:
"""Generate a short LLM synthesis of the current project state.
Reads the cached synthesis from project_state if available
(category=status, key=synthesis_cache). If not cached, returns
a deterministic summary from the existing structured data.
The actual LLM-generated synthesis is produced by the weekly
lint/synthesis pass on Dalidou (where claude CLI is available).
"""
entries = get_state(project)
cached = ""
for e in entries:
if e.category == "status" and e.key == "synthesis_cache":
cached = e.value
break
if cached:
return f"## Current State (auto-synthesis)\n\n> {cached}"
# Fallback: deterministic summary from structured data
stage = ""
summary = ""
next_focus = ""
for e in entries:
if e.category == "status":
if e.key == "stage":
stage = e.value
elif e.key == "summary":
summary = e.value
elif e.key == "next_focus":
next_focus = e.value
if not (stage or summary or next_focus):
return ""
bits = []
if summary:
bits.append(summary)
if stage:
bits.append(f"**Stage**: {stage}")
if next_focus:
bits.append(f"**Next**: {next_focus}")
return "## Current State\n\n" + "\n\n".join(bits)
def _header(project: str) -> str:
return (
f"# {project} — Project Overview\n\n"
f"> This page is auto-generated from AtoCore structured data.\n"
f"> It is a **derived view**, not canonical truth. "
f"If something is wrong here, fix the source data."
)
def _state_section(project: str) -> str:
entries = get_state(project)
if not entries:
return ""
lines = ["## Trusted Project State"]
by_category: dict[str, list] = {}
for e in entries:
by_category.setdefault(e.category.upper(), []).append(e)
for cat in ["DECISION", "REQUIREMENT", "STATUS", "FACT", "MILESTONE", "CONFIG", "CONTACT"]:
items = by_category.get(cat, [])
if not items:
continue
lines.append(f"\n### {cat.title()}")
for item in items:
value = item.value[:300]
lines.append(f"- **{item.key}**: {value}")
if item.source:
lines.append(f" *(source: {item.source})*")
return "\n".join(lines)
def _system_architecture(project: str) -> str:
systems = get_entities(entity_type="system", project=project)
subsystems = get_entities(entity_type="subsystem", project=project)
components = get_entities(entity_type="component", project=project)
interfaces = get_entities(entity_type="interface", project=project)
if not systems and not subsystems and not components:
return ""
lines = ["## System Architecture"]
for system in systems:
lines.append(f"\n### {system.name}")
if system.description:
lines.append(f"{system.description}")
rels = get_relationships(system.id, direction="outgoing")
children = []
for rel in rels:
if rel.relationship_type == "contains":
child = next(
(s for s in subsystems + components if s.id == rel.target_entity_id),
None,
)
if child:
children.append(child)
if children:
lines.append("\n**Contains:**")
for child in children:
desc = f"{child.description}" if child.description else ""
lines.append(f"- [{child.entity_type}] **{child.name}**{desc}")
child_rels = get_relationships(child.id, direction="both")
for cr in child_rels:
if cr.relationship_type in ("uses_material", "interfaces_with", "constrained_by"):
other_id = (
cr.target_entity_id
if cr.source_entity_id == child.id
else cr.source_entity_id
)
other = next(
(e for e in get_entities(project=project, limit=200)
if e.id == other_id),
None,
)
if other:
lines.append(
f" - *{cr.relationship_type}* → "
f"[{other.entity_type}] {other.name}"
)
return "\n".join(lines)
def _decisions_section(project: str) -> str:
decisions = get_entities(entity_type="decision", project=project)
if not decisions:
return ""
lines = ["## Decisions"]
for d in decisions:
lines.append(f"\n### {d.name}")
if d.description:
lines.append(d.description)
rels = get_relationships(d.id, direction="outgoing")
for rel in rels:
if rel.relationship_type == "affected_by_decision":
affected = next(
(e for e in get_entities(project=project, limit=200)
if e.id == rel.target_entity_id),
None,
)
if affected:
lines.append(
f"- Affects: [{affected.entity_type}] {affected.name}"
)
return "\n".join(lines)
def _requirements_section(project: str) -> str:
reqs = get_entities(entity_type="requirement", project=project)
constraints = get_entities(entity_type="constraint", project=project)
if not reqs and not constraints:
return ""
lines = ["## Requirements & Constraints"]
for r in reqs:
lines.append(f"- **{r.name}**: {r.description}" if r.description else f"- **{r.name}**")
for c in constraints:
lines.append(f"- [constraint] **{c.name}**: {c.description}" if c.description else f"- [constraint] **{c.name}**")
return "\n".join(lines)
def _materials_section(project: str) -> str:
materials = get_entities(entity_type="material", project=project)
if not materials:
return ""
lines = ["## Materials"]
for m in materials:
desc = f"{m.description}" if m.description else ""
lines.append(f"- **{m.name}**{desc}")
return "\n".join(lines)
def _vendors_section(project: str) -> str:
vendors = get_entities(entity_type="vendor", project=project)
if not vendors:
return ""
lines = ["## Vendors"]
for v in vendors:
desc = f"{v.description}" if v.description else ""
lines.append(f"- **{v.name}**{desc}")
return "\n".join(lines)
def _active_memories_section(project: str) -> str:
memories = get_memories(project=project, active_only=True, limit=20)
if not memories:
return ""
lines = ["## Active Memories"]
for m in memories:
conf = f" (conf: {m.confidence:.2f})" if m.confidence < 1.0 else ""
refs = f" | refs: {m.reference_count}" if m.reference_count > 0 else ""
lines.append(f"- [{m.memory_type}]{conf}{refs} {m.content[:200]}")
return "\n".join(lines)
def _footer(project: str) -> str:
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
return (
f"---\n\n"
f"*Generated by AtoCore Human Mirror at {now}. "
f"This is a derived view — not canonical truth.*"
)