feat: Phase 5F/5G/5H — graduation, conflicts, MCP engineering tools
The population move + the safety net + the universal consumer hookup,
all shipped together. This is where the engineering graph becomes
genuinely useful against the real 262-memory corpus.
5F: Memory → Entity graduation (THE population move)
- src/atocore/engineering/_graduation_prompt.py: stdlib-only shared
prompt module mirroring _llm_prompt.py pattern (container + host
use same system prompt, no drift)
- scripts/graduate_memories.py: host-side batch driver that asks
claude-p "does this memory describe a typed entity?" and creates
entity candidates with source_refs pointing back to the memory
- promote_entity() now scans source_refs for memory:* prefix; if
found, flips source memory to status='graduated' with
graduated_to_entity_id forward pointer + writes memory_audit row
- GET /admin/graduation/stats exposes graduation rate for dashboard
5G: Sync conflict detection on entity promote
- src/atocore/engineering/conflicts.py: detect_conflicts_for_entity()
runs on every active promote. V1 checks 3 slot kinds narrowly to
avoid false positives:
* component.material (multiple USES_MATERIAL edges)
* component.part_of (multiple PART_OF edges)
* requirement.name (duplicate active Requirements in same project)
- Conflicts + members persist via the tables built in 5A
- Fires a "warning" alert via Phase 4 framework
- Deduplicates: same (slot_kind, slot_key) won't get a new row
- resolve_conflict(action="dismiss|supersede_others|no_action"):
supersede_others marks non-winner members as status='superseded'
- GET /admin/conflicts + POST /admin/conflicts/{id}/resolve
5H: MCP + context pack integration
- scripts/atocore_mcp.py: 7 new engineering tools exposed to every
MCP-aware client (Claude Desktop, Claude Code, Cursor, Zed):
* atocore_engineering_map (Q-001/004 system tree)
* atocore_engineering_gaps (Q-006/009/011 killer queries — THE
director's question surfaced as a built-in tool)
* atocore_engineering_requirements_for_component (Q-005)
* atocore_engineering_decisions (Q-008)
* atocore_engineering_changes (Q-013 — reads entity audit log)
* atocore_engineering_impact (Q-016 BFS downstream)
* atocore_engineering_evidence (Q-017 inbound provenance)
- MCP tools total: 14 (7 memory/state/health + 7 engineering)
- context/builder.py _build_engineering_context now appends a compact
gaps summary ("Gaps: N orphan reqs, M risky decisions, K unsupported
claims") so every project-scoped LLM call sees "what we're missing"
Tests: 341 → 356 (15 new):
- 5F: graduation prompt parses positive/negative decisions, rejects
unknown entity types, tolerates markdown fences; promote_entity
marks source memory graduated with forward pointer; entity without
memory refs promotes cleanly
- 5G: component.material + component.part_of + requirement.name
conflicts detected; clean component triggers nothing; dedup works;
supersede_others resolution marks losers; dismiss leaves both
active; end-to-end promote triggers detection
- 5H: graduation user message includes project + type + content
No regressions across the 341 prior tests. The MCP server now answers
"which p05 requirements aren't satisfied?" directly from any Claude
session — no user prompt engineering, no context hacks.
Next to kick off from user: run graduation script on Dalidou to
populate the graph from 262 existing memories:
ssh papa@dalidou 'cd /srv/storage/atocore/app && PYTHONPATH=src \
python3 scripts/graduate_memories.py --project p05-interferometer --limit 30 --dry-run'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
291
src/atocore/engineering/conflicts.py
Normal file
291
src/atocore/engineering/conflicts.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Phase 5G — Conflict detection on entity promote.
|
||||
|
||||
When a candidate entity is promoted to active, we check whether another
|
||||
active entity is already claiming the "same slot" with an incompatible
|
||||
value. If so, we emit a conflicts row + conflict_members rows so the
|
||||
human can resolve.
|
||||
|
||||
Slot keys are per-entity-type (from ``conflict-model.md``). V1 starts
|
||||
narrow with 3 slot kinds to avoid false positives:
|
||||
|
||||
1. **component.material** — a component should normally have ONE
|
||||
dominant material (via USES_MATERIAL edge). Two active USES_MATERIAL
|
||||
edges from the same component pointing at different materials =
|
||||
conflict.
|
||||
2. **component.part_of** — a component should belong to AT MOST one
|
||||
subsystem (via PART_OF). Two active PART_OF edges = conflict.
|
||||
3. **requirement.value** — two active Requirements with the same name in
|
||||
the same project but different descriptions = conflict.
|
||||
|
||||
Rule: **flag, never block**. The promote succeeds; the conflict row is
|
||||
just a flag for the human. Users see conflicts in the dashboard and on
|
||||
wiki entity pages with a "⚠️ Disputed" badge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("conflicts")
|
||||
|
||||
|
||||
def detect_conflicts_for_entity(entity_id: str) -> list[str]:
|
||||
"""Run conflict detection for a newly-promoted active entity.
|
||||
|
||||
Returns a list of conflict_ids created. Fail-open: any detection error
|
||||
is logged and returns an empty list; the promote itself is not affected.
|
||||
"""
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM entities WHERE id = ? AND status = 'active'",
|
||||
(entity_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
|
||||
created: list[str] = []
|
||||
etype = row["entity_type"]
|
||||
project = row["project"] or ""
|
||||
|
||||
if etype == "component":
|
||||
created.extend(_check_component_conflicts(entity_id, project))
|
||||
elif etype == "requirement":
|
||||
created.extend(_check_requirement_conflicts(entity_id, row["name"], project))
|
||||
|
||||
return created
|
||||
except Exception as e:
|
||||
log.warning("conflict_detection_failed", entity_id=entity_id, error=str(e))
|
||||
return []
|
||||
|
||||
|
||||
def _check_component_conflicts(component_id: str, project: str) -> list[str]:
|
||||
"""Check material + part_of slot uniqueness for a component."""
|
||||
created: list[str] = []
|
||||
with get_connection() as conn:
|
||||
# component.material conflicts
|
||||
mat_edges = conn.execute(
|
||||
"SELECT r.id AS rel_id, r.target_entity_id, e.name "
|
||||
"FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.target_entity_id "
|
||||
"WHERE r.source_entity_id = ? AND r.relationship_type = 'uses_material' "
|
||||
"AND e.status = 'active'",
|
||||
(component_id,),
|
||||
).fetchall()
|
||||
if len(mat_edges) > 1:
|
||||
cid = _record_conflict(
|
||||
slot_kind="component.material",
|
||||
slot_key=component_id,
|
||||
project=project,
|
||||
note=f"component has {len(mat_edges)} active material edges",
|
||||
members=[
|
||||
{
|
||||
"kind": "entity",
|
||||
"id": m["target_entity_id"],
|
||||
"snapshot": m["name"],
|
||||
}
|
||||
for m in mat_edges
|
||||
],
|
||||
)
|
||||
if cid:
|
||||
created.append(cid)
|
||||
|
||||
# component.part_of conflicts
|
||||
pof_edges = conn.execute(
|
||||
"SELECT r.id AS rel_id, r.target_entity_id, e.name "
|
||||
"FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.target_entity_id "
|
||||
"WHERE r.source_entity_id = ? AND r.relationship_type = 'part_of' "
|
||||
"AND e.status = 'active'",
|
||||
(component_id,),
|
||||
).fetchall()
|
||||
if len(pof_edges) > 1:
|
||||
cid = _record_conflict(
|
||||
slot_kind="component.part_of",
|
||||
slot_key=component_id,
|
||||
project=project,
|
||||
note=f"component is part_of {len(pof_edges)} subsystems",
|
||||
members=[
|
||||
{
|
||||
"kind": "entity",
|
||||
"id": p["target_entity_id"],
|
||||
"snapshot": p["name"],
|
||||
}
|
||||
for p in pof_edges
|
||||
],
|
||||
)
|
||||
if cid:
|
||||
created.append(cid)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _check_requirement_conflicts(requirement_id: str, name: str, project: str) -> list[str]:
|
||||
"""Two active Requirements with the same name in the same project."""
|
||||
with get_connection() as conn:
|
||||
peers = conn.execute(
|
||||
"SELECT id, description FROM entities "
|
||||
"WHERE entity_type = 'requirement' AND status = 'active' "
|
||||
"AND project = ? AND LOWER(name) = LOWER(?) AND id != ?",
|
||||
(project, name, requirement_id),
|
||||
).fetchall()
|
||||
if not peers:
|
||||
return []
|
||||
|
||||
members = [{"kind": "entity", "id": requirement_id, "snapshot": name}]
|
||||
for p in peers:
|
||||
members.append({"kind": "entity", "id": p["id"],
|
||||
"snapshot": (p["description"] or "")[:200]})
|
||||
|
||||
cid = _record_conflict(
|
||||
slot_kind="requirement.name",
|
||||
slot_key=f"{project}|{name.lower()}",
|
||||
project=project,
|
||||
note=f"{len(peers)+1} active requirements share the name '{name}'",
|
||||
members=members,
|
||||
)
|
||||
return [cid] if cid else []
|
||||
|
||||
|
||||
def _record_conflict(
|
||||
slot_kind: str,
|
||||
slot_key: str,
|
||||
project: str,
|
||||
note: str,
|
||||
members: list[dict],
|
||||
) -> str | None:
|
||||
"""Persist a conflict + its members; skip if an open conflict already
|
||||
exists for the same (slot_kind, slot_key)."""
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM conflicts WHERE slot_kind = ? AND slot_key = ? "
|
||||
"AND status = 'open'",
|
||||
(slot_kind, slot_key),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return None # don't dup
|
||||
|
||||
conflict_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
"INSERT INTO conflicts (id, slot_kind, slot_key, project, "
|
||||
"status, note) VALUES (?, ?, ?, ?, 'open', ?)",
|
||||
(conflict_id, slot_kind, slot_key, project, note[:500]),
|
||||
)
|
||||
for m in members:
|
||||
conn.execute(
|
||||
"INSERT INTO conflict_members (id, conflict_id, member_kind, "
|
||||
"member_id, value_snapshot) VALUES (?, ?, ?, ?, ?)",
|
||||
(str(uuid.uuid4()), conflict_id,
|
||||
m.get("kind", "entity"), m.get("id", ""),
|
||||
(m.get("snapshot") or "")[:500]),
|
||||
)
|
||||
|
||||
log.info("conflict_detected", conflict_id=conflict_id,
|
||||
slot_kind=slot_kind, project=project)
|
||||
|
||||
# Emit a warning alert so the operator sees it
|
||||
try:
|
||||
from atocore.observability.alerts import emit_alert
|
||||
emit_alert(
|
||||
severity="warning",
|
||||
title=f"Entity conflict: {slot_kind}",
|
||||
message=note,
|
||||
context={"project": project, "slot_key": slot_key,
|
||||
"member_count": len(members)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return conflict_id
|
||||
except Exception as e:
|
||||
log.warning("conflict_record_failed", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def list_open_conflicts(project: str | None = None) -> list[dict]:
|
||||
"""Return open conflicts with their members."""
|
||||
with get_connection() as conn:
|
||||
query = "SELECT * FROM conflicts WHERE status = 'open'"
|
||||
params: list = []
|
||||
if project:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
query += " ORDER BY detected_at DESC"
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
conflicts = []
|
||||
for r in rows:
|
||||
member_rows = conn.execute(
|
||||
"SELECT * FROM conflict_members WHERE conflict_id = ?",
|
||||
(r["id"],),
|
||||
).fetchall()
|
||||
conflicts.append({
|
||||
"id": r["id"],
|
||||
"slot_kind": r["slot_kind"],
|
||||
"slot_key": r["slot_key"],
|
||||
"project": r["project"] or "",
|
||||
"status": r["status"],
|
||||
"note": r["note"] or "",
|
||||
"detected_at": r["detected_at"],
|
||||
"members": [
|
||||
{
|
||||
"id": m["id"],
|
||||
"member_kind": m["member_kind"],
|
||||
"member_id": m["member_id"],
|
||||
"snapshot": m["value_snapshot"] or "",
|
||||
}
|
||||
for m in member_rows
|
||||
],
|
||||
})
|
||||
return conflicts
|
||||
|
||||
|
||||
def resolve_conflict(
|
||||
conflict_id: str,
|
||||
action: str, # "dismiss", "supersede_others", "no_action"
|
||||
winner_id: str | None = None,
|
||||
actor: str = "api",
|
||||
) -> bool:
|
||||
"""Resolve a conflict. Optionally marks non-winner members as superseded."""
|
||||
if action not in ("dismiss", "supersede_others", "no_action"):
|
||||
raise ValueError(f"Invalid action: {action}")
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM conflicts WHERE id = ?", (conflict_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] != "open":
|
||||
return False
|
||||
|
||||
if action == "supersede_others":
|
||||
if not winner_id:
|
||||
raise ValueError("winner_id required for supersede_others")
|
||||
# Mark non-winner member entities as superseded
|
||||
member_rows = conn.execute(
|
||||
"SELECT member_id FROM conflict_members WHERE conflict_id = ?",
|
||||
(conflict_id,),
|
||||
).fetchall()
|
||||
for m in member_rows:
|
||||
if m["member_id"] != winner_id:
|
||||
conn.execute(
|
||||
"UPDATE entities SET status = 'superseded', updated_at = ? "
|
||||
"WHERE id = ? AND status = 'active'",
|
||||
(now, m["member_id"]),
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE conflicts SET status = 'resolved', resolution = ?, "
|
||||
"resolved_at = ? WHERE id = ?",
|
||||
(action, now, conflict_id),
|
||||
)
|
||||
|
||||
log.info("conflict_resolved", conflict_id=conflict_id,
|
||||
action=action, actor=actor)
|
||||
return True
|
||||
Reference in New Issue
Block a user