Initial commit: a0_software_orchestrator v1.0
- Auto-Registration-Bug behoben (register_project/get_project_id/resolve_project Trennung) - 25 Tests gruen (Pytest) - block_compactor-Tool refactored (Option B: Soft-Check statt Hard-Block) - 4 Restbaustellen gefixt - DB-Schema: plugin_settings-Tabelle hinzugefuegt - 3 Schattenprojekte aus DB geloescht - Plan v3 + Refactor-Plan + Worklog dokumentiert
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""Rules for required project artifacts (DB + repo hybrid)."""
|
||||
import os
|
||||
from typing import Dict, List
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-backed artifacts (5 mandatory: worklog, todo, project_state,
|
||||
# current_status, next_steps) – checked via db_state_store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DB_ARTIFACTS: List[str] = [
|
||||
"worklog", # orch_worklog (mindestens 1 Eintrag)
|
||||
"todo", # orch_todos (mindestens 1 Eintrag)
|
||||
"project_state", # orch_kv mit key='project_state' ODER 'phase'
|
||||
"current_status", # orch_status (gesetzt)
|
||||
"next_steps", # orch_next_steps (mindestens 1 pending)
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repo-backed artifacts (15 specs/docs/deploy files) – checked as file paths
|
||||
# unter project_root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REPO_ARTIFACTS: List[str] = [
|
||||
"specs/current/requirements.md",
|
||||
"specs/current/design.md",
|
||||
"specs/current/tasks.md",
|
||||
"docs/architecture.md",
|
||||
"docs/test_report.md",
|
||||
"docs/runtime_report.md",
|
||||
"deploy/env.md",
|
||||
"deploy/healthcheck.md",
|
||||
"deploy/runbook.md",
|
||||
"deploy/rollback.md",
|
||||
]
|
||||
|
||||
# All artifacts (kept for backward compat with the original list)
|
||||
CORE_ARTIFACTS = [f".a0/{a}.md" for a in DB_ARTIFACTS if a != "project_state"] + [
|
||||
".a0/project_state.json"
|
||||
] + REPO_ARTIFACTS
|
||||
|
||||
|
||||
def _check_db_artifacts(project_name: str) -> Dict[str, bool]:
|
||||
"""Prüft die 5 DB-backed Artefakte via db_state_store."""
|
||||
from .db_state_store import (
|
||||
get_project_id,
|
||||
get_kv,
|
||||
get_status,
|
||||
list_worklog,
|
||||
list_todos,
|
||||
list_next_steps,
|
||||
)
|
||||
|
||||
pid = get_project_id(project_name)
|
||||
if pid is None:
|
||||
return {
|
||||
"worklog": False, "todo": False, "project_state": False,
|
||||
"current_status": False, "next_steps": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"worklog": len(list_worklog(pid, limit=1)) > 0,
|
||||
"todo": len(list_todos(pid, status="open", limit=1)) > 0,
|
||||
"project_state": (
|
||||
get_kv(pid, "project_state") is not None
|
||||
or get_kv(pid, "phase") is not None
|
||||
),
|
||||
"current_status": get_status(pid) is not None,
|
||||
"next_steps": len(list_next_steps(pid, status="pending", limit=1)) > 0,
|
||||
}
|
||||
|
||||
|
||||
def _check_repo_artifacts(project_root: str) -> Dict[str, bool]:
|
||||
"""Prüft die 15 Repo-Artefakte (specs/, docs/, deploy/) per File-Existenz."""
|
||||
result: Dict[str, bool] = {}
|
||||
for rel_path in REPO_ARTIFACTS:
|
||||
result[rel_path] = os.path.exists(os.path.join(project_root, rel_path))
|
||||
return result
|
||||
|
||||
|
||||
def check_required_artifacts(
|
||||
project_name: str = "",
|
||||
project_root: str = "",
|
||||
) -> dict:
|
||||
"""Prüft alle Pflicht-Artefakte (DB + Repo).
|
||||
|
||||
Args:
|
||||
project_name: Projektname (für DB-Checks). Default: aus project_root
|
||||
abgeleitet (basename).
|
||||
project_root: Projekt-Wurzelverzeichnis (für Repo-Checks). Default: cwd.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"db": {artifact_name: bool, …},
|
||||
"repo": {relative_path: bool, …},
|
||||
"present": [alle existierenden], # kombiniert
|
||||
"missing": [alle fehlenden], # kombiniert
|
||||
"total": int,
|
||||
}
|
||||
"""
|
||||
if not project_name:
|
||||
project_name = os.path.basename(project_root or os.getcwd())
|
||||
if not project_root:
|
||||
project_root = os.getcwd()
|
||||
|
||||
db = _check_db_artifacts(project_name)
|
||||
repo = _check_repo_artifacts(project_root)
|
||||
|
||||
present = [a for a, ok in db.items() if ok] + [a for a, ok in repo.items() if ok]
|
||||
missing = [a for a, ok in db.items() if not ok] + [a for a, ok in repo.items() if not ok]
|
||||
|
||||
return {
|
||||
"db": db,
|
||||
"repo": repo,
|
||||
"present": present,
|
||||
"missing": missing,
|
||||
"total": len(db) + len(repo),
|
||||
}
|
||||
|
||||
|
||||
# Backward-compat: altes Format (nur Repo-Files) für externe Caller
|
||||
def check_legacy_artifacts(project_root: str) -> dict:
|
||||
"""Alte CORE_ARTIFACTS-Liste als File-Existenz-Check (nur Repo)."""
|
||||
missing = []
|
||||
present = []
|
||||
for artifact in CORE_ARTIFACTS:
|
||||
path = os.path.join(project_root, artifact)
|
||||
if os.path.exists(path):
|
||||
present.append(artifact)
|
||||
else:
|
||||
missing.append(artifact)
|
||||
return {"present": present, "missing": missing, "total": len(CORE_ARTIFACTS)}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Briefing helpers for specialist handover – DB-backed.
|
||||
|
||||
Vorher: persistierte als .md-Datei in <project_root>/.a0proj/handover/.
|
||||
Jetzt: append-only in der Tabelle `orch_briefings` (patterns.db).
|
||||
|
||||
Kompatible Rückgabe-Schemata, aber statt "path" jetzt "briefing_id".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
get_project_id,
|
||||
append_briefing,
|
||||
get_briefing,
|
||||
list_briefings,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.helpers.handover_protocol import is_known_specialist
|
||||
|
||||
|
||||
def ensure_handover_dir(project_root: str = "", project_name: str = "") -> Dict[str, Any]:
|
||||
"""No-op in DB-mode. Wird für Rückwärtskompatibilität gehalten und
|
||||
gibt statt eines Pfads einfach einen Marker-Dict zurück.
|
||||
"""
|
||||
return {"kind": "db", "project_name": project_name or None}
|
||||
|
||||
|
||||
def write_initial_briefing(
|
||||
*,
|
||||
project_name: str = "",
|
||||
project_root: str = "",
|
||||
specialist: str,
|
||||
topic: str,
|
||||
summary: str,
|
||||
decisions: List[str],
|
||||
artifacts: List[str],
|
||||
recent_turns: List[str],
|
||||
return_condition: str,
|
||||
handover_reason: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Hängt eine neue Briefing-Section an `orch_briefings` an.
|
||||
|
||||
Returns dict mit:
|
||||
- briefing_id (DB-PK)
|
||||
- project_id
|
||||
- project_name
|
||||
- specialist
|
||||
- topic
|
||||
- created_at
|
||||
- newly_created (immer True – es gibt keine "vorhanden" Files mehr)
|
||||
- existed_before (deprecated, immer False für neue Einträge)
|
||||
- size_bytes (Größe des section_md-Texts in Bytes)
|
||||
"""
|
||||
if not is_known_specialist(specialist):
|
||||
raise ValueError(f"Unknown specialist profile: {specialist!r}")
|
||||
|
||||
# Projekt-Name ableiten: project_name ist Pflicht (basename-Fallback
|
||||
# entfernt – würde ungültige/Phantom-Namen erzeugen).
|
||||
if not project_name or not project_name.strip():
|
||||
raise ValueError(
|
||||
"project_name is required (basename-fallback removed; "
|
||||
"pass explicit project_name or registered name)"
|
||||
)
|
||||
name = project_name.strip()
|
||||
pid = get_project_id(name)
|
||||
if pid is None:
|
||||
raise LookupError(f"project '{name}' is not registered")
|
||||
|
||||
# Section-Markdown aufbauen (aus handover_protocol)
|
||||
from .handover_protocol import render_briefing_section
|
||||
section_md = render_briefing_section(
|
||||
specialist=specialist,
|
||||
topic=topic,
|
||||
summary=summary,
|
||||
decisions=decisions,
|
||||
artifacts=artifacts,
|
||||
recent_turns=recent_turns,
|
||||
return_condition=return_condition,
|
||||
handover_reason=handover_reason,
|
||||
)
|
||||
|
||||
briefing_id = append_briefing(
|
||||
project_id=pid,
|
||||
specialist=specialist,
|
||||
topic=topic,
|
||||
section_md=section_md,
|
||||
summary=summary,
|
||||
decisions=decisions,
|
||||
artifacts=artifacts,
|
||||
recent_turns=recent_turns,
|
||||
return_condition=return_condition,
|
||||
handover_reason=handover_reason,
|
||||
)
|
||||
|
||||
return {
|
||||
"briefing_id": briefing_id,
|
||||
"project_id": pid,
|
||||
"project_name": name,
|
||||
"specialist": specialist,
|
||||
"topic": topic,
|
||||
"newly_created": True,
|
||||
"existed_before": False,
|
||||
"size_bytes": len(section_md.encode("utf-8")),
|
||||
}
|
||||
|
||||
|
||||
def read_briefing(
|
||||
briefing_id: int,
|
||||
max_bytes: int = 32_000,
|
||||
) -> str:
|
||||
"""Liest den Markdown-Text eines Briefings aus der DB.
|
||||
|
||||
Returns "" wenn nicht gefunden. Truncated mit Marker wenn zu lang.
|
||||
"""
|
||||
full = get_briefing(int(briefing_id))
|
||||
if not full:
|
||||
return ""
|
||||
content = full.get("section_md") or ""
|
||||
if len(content.encode("utf-8")) <= max_bytes:
|
||||
return content
|
||||
truncated = content.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")
|
||||
return truncated + "\n\n_…[truncated, use a smaller max_bytes or split topic]…_\n"
|
||||
|
||||
|
||||
def list_briefings_for(
|
||||
project_name: str = "",
|
||||
project_root: str = "",
|
||||
specialist: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Listet alle Briefings (latest first) eines Projekts.
|
||||
|
||||
Komfort-Wrapper um db_state_store.list_briefings mit
|
||||
project_name/project_root-Auflösung.
|
||||
"""
|
||||
# Bug-Fix: basename-Fallback entfernt – würde ungültige/Phantom-Namen
|
||||
# erzeugen. project_name ist Pflicht; ohne Name → leere Liste.
|
||||
if not project_name or not project_name.strip():
|
||||
return []
|
||||
name = project_name.strip()
|
||||
pid = get_project_id(name)
|
||||
if pid is None:
|
||||
return []
|
||||
return list_briefings(pid, specialist=specialist, limit=limit)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Block Compact Protocol – Schwellen, Pflicht-Artefakte und sichere Trigger.
|
||||
|
||||
NEU (additiv, DB-backed): Definiert das Block-Compact-Protokoll für den
|
||||
A0 Software Orchestrator. Verwendet die DB-Tabellen orch_* (Migration v3).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import yaml
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
get_project_id,
|
||||
list_next_steps,
|
||||
list_worklog,
|
||||
list_todos,
|
||||
list_kv_keys,
|
||||
get_kv,
|
||||
get_status,
|
||||
_db,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin-Root für Config-Lookup (default_config.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schwellen (werden zur Laufzeit aus config.json block_compact überschrieben)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_THRESHOLDS: Dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"threshold_warn": 0.8,
|
||||
"threshold_hard": 0.9,
|
||||
"min_lines_user_status": 30,
|
||||
"max_conversation_summary_lines": 200,
|
||||
"require_user_approval": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pflicht-Artefakte – werden in der DB geprüft (nicht mehr als Files!)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MANDATORY_ARTIFACTS: List[str] = [
|
||||
"worklog", # orch_worklog: mind. 1 Eintrag
|
||||
"todo", # orch_todos: mind. 1 open todo
|
||||
"project_state", # orch_kv key='project_state' oder 'phase'
|
||||
"current_status", # orch_status: gesetzt
|
||||
"next_steps", # orch_next_steps: mind. 1 pending
|
||||
]
|
||||
|
||||
# Zusätzliche Artefakte, die das Block-Compact-Protokoll schreibt
|
||||
BLOCK_COMPACT_EXTRA_ARTIFACTS: List[str] = [
|
||||
"block_compact_marker", # orch_block_compact (DB)
|
||||
"snapshot", # orch_snapshots (DB)
|
||||
"worklog_entry", # orch_worklog Eintrag (DB)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger-Bedingungen – wann ein Compact SICHER ist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_block_compact_config(project_root: str = "") -> Dict[str, Any]:
|
||||
"""Liest die block_compact-Konfiguration aus Plugin- und Projekt-Config.
|
||||
|
||||
Args:
|
||||
project_root: Optional. Wird hier nicht mehr benötigt (DB-basiert),
|
||||
aber als Parameter behalten für Rückwärtskompatibilität.
|
||||
|
||||
Returns:
|
||||
Konfig-Dict mit aktivierten Schwellen etc.
|
||||
"""
|
||||
config = dict(DEFAULT_THRESHOLDS)
|
||||
|
||||
# Suche zuerst nach <project_root>/.a0/config.json
|
||||
candidate_paths = []
|
||||
if project_root:
|
||||
candidate_paths.append(
|
||||
(os.path.join(project_root, ".a0", "config.json"), json.load)
|
||||
)
|
||||
# Fallback: Plugin-Default
|
||||
candidate_paths.append(
|
||||
(os.path.join(_PLUGIN_ROOT, "default_config.yaml"), yaml.safe_load)
|
||||
)
|
||||
|
||||
for path, loader in candidate_paths:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = loader(f)
|
||||
except (json.JSONDecodeError, yaml.YAMLError, OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
block_cfg = data.get("block_compact", {})
|
||||
if isinstance(block_cfg, dict) and block_cfg:
|
||||
config.update(block_cfg)
|
||||
break
|
||||
return config
|
||||
|
||||
|
||||
def should_block_compact(
|
||||
project_name: str = "",
|
||||
project_root: str = "",
|
||||
estimated_ratio: float = 0.0,
|
||||
open_subagent_calls: int = 0,
|
||||
quality_gate_passed: bool = True,
|
||||
user_gave_new_instruction: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Entscheidet, ob ein Block-Compact jetzt SICHER ausgelöst werden darf.
|
||||
|
||||
Args:
|
||||
project_name: Projektname (DB-Lookup, bevorzugt).
|
||||
project_root: Legacy – wird zu basename() aufgelöst.
|
||||
estimated_ratio: Token-Verbrauch (0.0–1.0).
|
||||
open_subagent_calls: Anzahl offener Subagent-Calls.
|
||||
quality_gate_passed: True, wenn Quality-Gate passed.
|
||||
user_gave_new_instruction: True, wenn User in dieser Runde neue Anweisung gab.
|
||||
|
||||
Returns:
|
||||
dict mit allowed, forced, reason, config.
|
||||
"""
|
||||
# Bug-Fix: basename-Fallback entfernt – würde ungültige/Phantom-Namen
|
||||
# erzeugen. project_name ist Pflicht.
|
||||
if not project_name or not project_name.strip():
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "project_name is required (basename-fallback removed)",
|
||||
"config": get_block_compact_config(project_root=project_root),
|
||||
}
|
||||
name = project_name.strip()
|
||||
config = get_block_compact_config(project_root=project_root)
|
||||
|
||||
# 1. Disabled?
|
||||
if not config.get("enabled", True):
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "block_compact disabled in config", "config": config,
|
||||
}
|
||||
|
||||
# 2. Quality-Gate failed?
|
||||
if not quality_gate_passed:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "quality_gate failed – fix first, compact after",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 3. Offene Subagent-Calls?
|
||||
if open_subagent_calls > 0:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": f"{open_subagent_calls} subagent call(s) still open",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 4. User hat neue Anweisung in dieser Runde gegeben?
|
||||
if user_gave_new_instruction:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "user gave new instruction this turn – process first",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 5. Pflicht-Artefakte vorhanden?
|
||||
# (Refactor Option B / Soft-Check 2026-06-16: HARD-Block entfernt.
|
||||
# Stattdessen prüft block_compactor.execute die Artefakte als
|
||||
# WARN und schreibt trotzdem. Diese Funktion behält Token-Ratio,
|
||||
# Subagent, Quality-Gate, User-Instruction, next_steps als BLOCK.)
|
||||
# artifacts = check_mandatory_artifacts(name) # Soft-Check-Pfad: block_compactor
|
||||
# for artifact_key, ok in artifacts.items():
|
||||
# if not ok:
|
||||
# return {
|
||||
# "allowed": False, "forced": False,
|
||||
# "reason": f"mandatory artifact missing: {artifact_key}",
|
||||
# "config": config,
|
||||
# }
|
||||
|
||||
# 6. next_steps nicht leer
|
||||
try:
|
||||
pid = get_project_id(name)
|
||||
if pid is None:
|
||||
raise LookupError(f"project '{name}' not found in DB")
|
||||
pending_steps = list_next_steps(pid, status="pending")
|
||||
if not pending_steps:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "orch_next_steps has no pending entries – next block not defined",
|
||||
"config": config,
|
||||
}
|
||||
except (ValueError, LookupError):
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": f"project {name!r} not found in DB",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 7. Token-Schwelle
|
||||
threshold_warn = float(config.get("threshold_warn", 0.8))
|
||||
threshold_hard = float(config.get("threshold_hard", 0.9))
|
||||
forced = estimated_ratio >= threshold_hard
|
||||
if estimated_ratio < threshold_warn and not forced:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": f"context ratio {estimated_ratio:.2f} below warn-threshold {threshold_warn:.2f}",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"forced": forced,
|
||||
"reason": "all preconditions met",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
|
||||
def _has_working_todo_table(pid: int) -> bool:
|
||||
"""Prüft, ob die Todo-Tabelle vorhanden und das Schema nutzbar ist.
|
||||
|
||||
Hintergrund (Bugfix): Vorher wurde 'mind. 1 open todo' als Pflicht
|
||||
verlangt. Das blockierte das Block-Compact-Protokoll fälschlich,
|
||||
wenn die Session beendet ist und keine offenen Todos anstehen
|
||||
(gültiger Zustand). Korrekt ist: Todo-Mechanismus ist verfügbar =
|
||||
Tabelle existiert + die für die Listen-Abfrage nötigen Spalten sind da.
|
||||
"""
|
||||
db = _db()
|
||||
# 1. Tabelle existiert?
|
||||
row = db.conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='orch_todos'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
# 2. Erforderliche Spalten vorhanden?
|
||||
required = {"id", "project_id", "status"}
|
||||
cols = {r[1] for r in db.conn.execute("PRAGMA table_info(orch_todos)").fetchall()}
|
||||
return required.issubset(cols)
|
||||
|
||||
|
||||
def check_mandatory_artifacts(project_name: str) -> Dict[str, bool]:
|
||||
"""Prüft, ob alle 5 Pflicht-Artefakte (DB) vorhanden sind.
|
||||
|
||||
Returns:
|
||||
dict artifact_key -> exists (bool)
|
||||
"""
|
||||
try:
|
||||
pid = get_project_id(project_name)
|
||||
if pid is None:
|
||||
raise LookupError(f"project {project_name!r} not in DB")
|
||||
except (ValueError, LookupError):
|
||||
# Projekt nicht in DB → alle Artefakte fehlen
|
||||
return {k: False for k in MANDATORY_ARTIFACTS}
|
||||
|
||||
return {
|
||||
"worklog": len(list_worklog(pid, limit=1)) > 0,
|
||||
"todo": _has_working_todo_table(pid),
|
||||
"project_state": (
|
||||
get_kv(pid, "project_state") is not None
|
||||
or get_kv(pid, "phase") is not None
|
||||
),
|
||||
"current_status": get_status(pid) is not None,
|
||||
"next_steps": len(list_next_steps(pid, status="pending", limit=1)) > 0,
|
||||
}
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""ISO-8601 UTC timestamp."""
|
||||
return datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
|
||||
def get_resume_payload(project_name: str) -> Dict[str, Any]:
|
||||
"""Liest alle Resume-relevanten Daten aus der DB für `block_resume.py`."""
|
||||
try:
|
||||
pid = get_project_id(project_name)
|
||||
except (ValueError, LookupError):
|
||||
pid = None
|
||||
|
||||
if pid is None:
|
||||
return {
|
||||
"project_name": project_name,
|
||||
"resume_exists": False,
|
||||
"conversation_summary_exists": False,
|
||||
"project_state": {},
|
||||
"next_steps": "",
|
||||
}
|
||||
|
||||
from .db_state_store import get_block_compact
|
||||
bc = get_block_compact(pid) or {}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"project_name": project_name,
|
||||
"resume_exists": bool(bc.get("resume_md")),
|
||||
"conversation_summary_exists": bool(bc.get("conversation_summary_md")),
|
||||
"project_state": {
|
||||
"project_id": pid,
|
||||
"block_compact": bc,
|
||||
},
|
||||
"next_steps": list_next_steps(pid, status="pending"),
|
||||
}
|
||||
if bc.get("resume_md"):
|
||||
payload["resume"] = bc["resume_md"]
|
||||
if bc.get("conversation_summary_md"):
|
||||
payload["conversation_summary"] = bc["conversation_summary_md"]
|
||||
return payload
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Context budget estimation and compaction."""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Rough token count estimation (chars / 4)."""
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, len(text) // 4)
|
||||
|
||||
|
||||
def estimate_ratio(estimated_tokens: int, max_context_tokens: int) -> float:
|
||||
"""Return ratio of used tokens to max context."""
|
||||
if max_context_tokens <= 0:
|
||||
return 0.0
|
||||
return estimated_tokens / max_context_tokens
|
||||
|
||||
|
||||
def should_warn(ratio: float, threshold: float = 0.8) -> bool:
|
||||
"""Check if a warning should be issued based on usage ratio."""
|
||||
return ratio >= threshold
|
||||
|
||||
|
||||
def should_hard_compact(ratio: float, threshold: float = 0.9) -> bool:
|
||||
"""Check if forced compaction is needed."""
|
||||
return ratio >= threshold
|
||||
|
||||
|
||||
def compact_text(text: str, max_lines: int = 80) -> str:
|
||||
"""Compress text by keeping head and tail lines, inserting a compaction marker."""
|
||||
lines = text.splitlines()
|
||||
if len(lines) <= max_lines:
|
||||
return text
|
||||
head = lines[:max_lines // 2]
|
||||
tail = lines[-max_lines // 2:]
|
||||
return "\n".join(head + ["", "... [compacted] ...", ""] + tail)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
"""Self-test runner for the orchestrator plugin."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
EXPECTED_AGENTS = [
|
||||
"a0_software_orchestrator",
|
||||
"codebase_explorer",
|
||||
"requirements_analyst",
|
||||
"solution_architect",
|
||||
"implementation_engineer",
|
||||
"test_debug_engineer",
|
||||
"runtime_devops_engineer",
|
||||
"security_data_engineer",
|
||||
"release_auditor",
|
||||
"quality_reviewer",
|
||||
"deploy_agent",
|
||||
]
|
||||
|
||||
EXPECTED_TOOLS = [
|
||||
"artifact_guard.py",
|
||||
"block_compactor.py",
|
||||
"block_resume.py",
|
||||
"capability_check.py",
|
||||
"context_compactor.py",
|
||||
"handover_to_specialist.py",
|
||||
"next_step.py",
|
||||
"orchestrator_state.py",
|
||||
"plan_mode_guard.py",
|
||||
"project_registry.py",
|
||||
"quality_gate.py",
|
||||
"read_briefing.py",
|
||||
"repo_manifest.py",
|
||||
"resume_checker.py",
|
||||
"scorecard_update.py",
|
||||
"tool_registry.py",
|
||||
"orchestrator_self_check.py",
|
||||
]
|
||||
|
||||
|
||||
def check_structure() -> List[Tuple[str, bool, str]]:
|
||||
results: List[Tuple[str, bool, str]] = []
|
||||
results.append(("plugin.yaml exists", os.path.exists(os.path.join(PLUGIN_DIR, "plugin.yaml")), ""))
|
||||
results.append((".toggle-1 exists", os.path.exists(os.path.join(PLUGIN_DIR, ".toggle-1")), ""))
|
||||
results.append((".toggle-0 absent", not os.path.exists(os.path.join(PLUGIN_DIR, ".toggle-0")), ""))
|
||||
|
||||
expected_top = {
|
||||
"plugin.yaml", "execute.py", "hooks.py", "default_config.yaml", "config.json",
|
||||
"README.md", "LICENSE", "__init__.py", "agents", "api", "tools",
|
||||
"helpers", "prompts", "extensions", "webui", "help", "scripts", "utils",
|
||||
}
|
||||
for item in os.listdir(PLUGIN_DIR):
|
||||
if item.startswith("."):
|
||||
continue
|
||||
if item not in expected_top:
|
||||
results.append((f"unexpected top-level: {item}", False, ""))
|
||||
|
||||
# Packaging hygiene: release zips must not contain runtime/compiled artifacts.
|
||||
bad_artifacts = []
|
||||
for dirpath, dirnames, filenames in os.walk(PLUGIN_DIR):
|
||||
rel_dir = os.path.relpath(dirpath, PLUGIN_DIR)
|
||||
if "__pycache__" in dirpath.split(os.sep):
|
||||
bad_artifacts.append(rel_dir)
|
||||
for fname in filenames:
|
||||
if fname.endswith(".pyc") or fname.endswith(".pyo") or fname.endswith(".db") or fname.startswith("patterns_backup_"):
|
||||
bad_artifacts.append(os.path.join(rel_dir, fname))
|
||||
results.append(("no runtime/compiled artifacts", len(bad_artifacts) == 0, ", ".join(bad_artifacts[:10])))
|
||||
|
||||
agents_dir = os.path.join(PLUGIN_DIR, "agents")
|
||||
for agent in EXPECTED_AGENTS:
|
||||
agent_dir = os.path.join(agents_dir, agent)
|
||||
results.append((f"agent {agent}: agent.yaml", os.path.exists(os.path.join(agent_dir, "agent.yaml")), ""))
|
||||
results.append((f"agent {agent}: specifics.md", os.path.exists(os.path.join(agent_dir, "prompts", "agent.system.main.specifics.md")), ""))
|
||||
results.append((f"agent {agent}: quality_contract.md", os.path.exists(os.path.join(agent_dir, "quality_contract.md")), ""))
|
||||
|
||||
tools_dir = os.path.join(PLUGIN_DIR, "tools")
|
||||
for tool in EXPECTED_TOOLS:
|
||||
results.append((f"tool {tool}", os.path.exists(os.path.join(tools_dir, tool)), ""))
|
||||
|
||||
ext_dir = os.path.join(PLUGIN_DIR, "extensions", "python")
|
||||
for ext in [
|
||||
"system_prompt/inject_orchestrator_rules.py",
|
||||
"system_prompt/_30_inject_profile_specifics.py",
|
||||
"monologue_start/load_project_state.py",
|
||||
"message_loop_prompts_after/inject_context_budget.py",
|
||||
"tool_execute_before/guard_risky_actions.py",
|
||||
"monologue_end/remind_state_update.py",
|
||||
]:
|
||||
results.append((f"extension {ext}", os.path.exists(os.path.join(ext_dir, ext)), ""))
|
||||
results.append(("fake tool-list injection extension absent", not os.path.exists(os.path.join(ext_dir, "system_prompt", "_20_inject_tool_list.py")), ""))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_self_test() -> int:
|
||||
print("=== A0 Software Orchestrator Self-Test ===\n")
|
||||
results = check_structure()
|
||||
passed = 0
|
||||
failed = 0
|
||||
for name, ok, msg in results:
|
||||
if ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {name}{f' ({msg})' if msg else ''}")
|
||||
print("\n---")
|
||||
print(f"Total: {passed} passed, {failed} failed")
|
||||
print("Result: PASS" if failed == 0 else "Result: FAIL")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(run_self_test())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Custom exceptions for the orchestrator plugin."""
|
||||
|
||||
class PluginDBError(Exception):
|
||||
"""Raised when a database operation fails."""
|
||||
pass
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Handover protocol helpers for specialist subagents (Phase 1 - read-only spike).
|
||||
|
||||
Implements the 3-stage context transfer pattern documented in
|
||||
/usr/workdir/handover-spike.md (sections 4 and 5).
|
||||
|
||||
This module is a HELPER (not a Tool). It exposes pure functions that the
|
||||
HandoverToSpecialist tool and future code can call. It is intentionally
|
||||
LLM-free in Phase 1: the orchestrator is expected to pre-fill the LLM
|
||||
summary before invoking the tool. The actual LLM summarisation belongs in
|
||||
a later phase.
|
||||
|
||||
Protection rules (enforced here):
|
||||
- never compact mid-subagent
|
||||
- never skip snapshot (callers MUST call snapshot_state first)
|
||||
- never overwrite resume/conversation_summary with empty content
|
||||
- never use chat history as project memory (briefing files are
|
||||
curated LLM summaries, not raw chat dumps)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.context_budget import (
|
||||
estimate_tokens,
|
||||
should_warn,
|
||||
)
|
||||
|
||||
|
||||
# Known specialist subagent profile directories. The orchestrator will only
|
||||
# TODO: Centralize this allow-list so call_subordinate enforces it too.
|
||||
# Currently call_subordinate can invoke ANY profile, bypassing this check.
|
||||
# accept handovers to profiles in this allow-list. Update this list when new
|
||||
# profiles are added to /a0/usr/plugins/a0_software_orchestrator/agents/.
|
||||
KNOWN_SPECIALISTS = {
|
||||
"a0_software_orchestrator",
|
||||
"codebase_explorer",
|
||||
"deploy_agent",
|
||||
"implementation_engineer",
|
||||
"quality_reviewer",
|
||||
"release_auditor",
|
||||
"requirements_analyst",
|
||||
"runtime_devops_engineer",
|
||||
"security_data_engineer",
|
||||
"solution_architect",
|
||||
"test_debug_engineer",
|
||||
"hacker",
|
||||
}
|
||||
|
||||
# Hard caps (token budget). All numbers are conservative defaults that match
|
||||
# section 4 of /usr/workdir/handover-spike.md.
|
||||
DEFAULT_MAX_CONTEXT_TOKENS = 3000
|
||||
DEFAULT_RECENT_TURNS = 5
|
||||
HARD_CAP_HISTORY_TOKENS = 50_000
|
||||
MAX_HANDOVER_DEPTH = 3
|
||||
|
||||
|
||||
# Filename safety: keep this strict so we never escape the project root via a
|
||||
# malicious topic name. We allow letters, digits, dashes, underscores and dots.
|
||||
_SAFE_SLUG = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||
|
||||
|
||||
def _safe_slug(value: str) -> str:
|
||||
"""Sanitise a free-form string into a filename-safe slug."""
|
||||
cleaned = _SAFE_SLUG.sub("-", value.strip())
|
||||
cleaned = cleaned.strip("-")
|
||||
return cleaned or "handover"
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _timestamp_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def list_known_specialists() -> list[str]:
|
||||
"""Return the sorted list of accepted specialist profile names."""
|
||||
return sorted(KNOWN_SPECIALISTS)
|
||||
|
||||
|
||||
def is_known_specialist(profile: str) -> bool:
|
||||
"""True if `profile` is in the handover allow-list."""
|
||||
return profile in KNOWN_SPECIALISTS
|
||||
|
||||
|
||||
def build_briefing_path(project_root: str, specialist: str, topic: str) -> str:
|
||||
"""Return the absolute path where a briefing file should be written.
|
||||
|
||||
Layout: <project_root>/.a0proj/handover/<specialist>-<topic>-<date>.md
|
||||
The directory is created on demand by append_briefing().
|
||||
"""
|
||||
if not is_known_specialist(specialist):
|
||||
raise ValueError(f"Unknown specialist profile: {specialist!r}")
|
||||
slug = _safe_slug(topic)
|
||||
filename = f"{_safe_slug(specialist)}-{slug}-{_today_iso()}.md"
|
||||
return os.path.join(project_root, ".a0proj", "handover", filename)
|
||||
|
||||
|
||||
def render_briefing_section(
|
||||
*,
|
||||
specialist: str,
|
||||
topic: str,
|
||||
summary: str,
|
||||
decisions: list[str],
|
||||
artifacts: list[str],
|
||||
recent_turns: list[str],
|
||||
return_condition: str,
|
||||
handover_reason: str,
|
||||
) -> str:
|
||||
"""Render the markdown content for a new briefing-file section.
|
||||
|
||||
This is the canonical layout. The orchestrator (or the tool) appends the
|
||||
result to a per-specialist briefing file. Sections are append-only and
|
||||
timestamped, in line with the 'never overwrite' protection rule.
|
||||
"""
|
||||
lines = [
|
||||
f"## {specialist} – {topic} ({_timestamp_iso()})",
|
||||
"",
|
||||
f"**Handover reason:** {handover_reason.strip() or 'n/a'}",
|
||||
"",
|
||||
"**Summary (LLM-curated, max 300 tokens):**",
|
||||
"",
|
||||
summary.strip() or "_(no summary provided)_",
|
||||
"",
|
||||
"**Recent turns (verbatim, last N):**",
|
||||
"",
|
||||
]
|
||||
if recent_turns:
|
||||
for turn in recent_turns:
|
||||
lines.append(f"> {turn.strip()}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("_(no recent turns provided)_")
|
||||
lines.append("")
|
||||
lines.append("**Decisions so far:**")
|
||||
lines.append("")
|
||||
if decisions:
|
||||
for d in decisions:
|
||||
lines.append(f"- {d}")
|
||||
else:
|
||||
lines.append("- _(none yet)_")
|
||||
lines.append("")
|
||||
lines.append("**Artifacts (paths/references):**")
|
||||
lines.append("")
|
||||
if artifacts:
|
||||
for a in artifacts:
|
||||
lines.append(f"- `{a}`")
|
||||
else:
|
||||
lines.append("- _(none yet)_")
|
||||
lines.append("")
|
||||
lines.append(f"**Return condition:** {return_condition.strip() or 'user_request'}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def append_briefing(path: str, section_markdown: str) -> int:
|
||||
"""Append `section_markdown` to the briefing file at `path`.
|
||||
|
||||
Creates the parent directory if it does not exist. Returns the new file
|
||||
size in bytes. Raises OSError on filesystem errors.
|
||||
"""
|
||||
if not section_markdown or not section_markdown.strip():
|
||||
# Protection rule: never overwrite/append with empty content.
|
||||
raise ValueError("refusing to append empty briefing content")
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
# Append-only: open in append mode, never in write/truncate mode.
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(section_markdown)
|
||||
if not section_markdown.endswith("\n"):
|
||||
fh.write("\n")
|
||||
return os.path.getsize(path)
|
||||
|
||||
|
||||
def estimate_handover_tokens(
|
||||
*,
|
||||
summary: str,
|
||||
recent_turns: list[str],
|
||||
decisions: list[str],
|
||||
artifacts: list[str],
|
||||
) -> int:
|
||||
"""Rough token estimate of the stage-1 handover package (no system prompt)."""
|
||||
text_parts = [summary or ""]
|
||||
text_parts.extend(recent_turns or [])
|
||||
text_parts.extend(decisions or [])
|
||||
text_parts.extend(artifacts or [])
|
||||
blob = "\n".join(text_parts)
|
||||
return estimate_tokens(blob)
|
||||
|
||||
|
||||
def validate_history_request(max_tokens: int) -> int:
|
||||
"""Validate a stage-2 history request against the hard cap.
|
||||
|
||||
Returns the effective token budget (clamped to HARD_CAP_HISTORY_TOKENS).
|
||||
Raises ValueError if the caller asks for zero or negative tokens.
|
||||
"""
|
||||
if max_tokens <= 0:
|
||||
raise ValueError("max_tokens must be positive")
|
||||
return min(max_tokens, HARD_CAP_HISTORY_TOKENS)
|
||||
|
||||
|
||||
def build_handover_package(
|
||||
*,
|
||||
specialist: str,
|
||||
topic: str,
|
||||
summary: str,
|
||||
recent_turns: list[str],
|
||||
decisions: list[str],
|
||||
artifacts: list[str],
|
||||
return_condition: str,
|
||||
handover_reason: str,
|
||||
project_root: str,
|
||||
max_context_tokens: int = DEFAULT_MAX_CONTEXT_TOKENS,
|
||||
) -> dict:
|
||||
"""Build a stage-1 handover package as a plain dict.
|
||||
|
||||
The dict contains both the metadata the subagent will receive AND the
|
||||
resolved briefing-file path. The caller (tool) is responsible for writing
|
||||
the briefing file via append_briefing() before invoking the subagent.
|
||||
|
||||
Raises ValueError for unknown specialists, oversized payloads, or
|
||||
empty summary / topic (the latter is a soft warning, not a hard error,
|
||||
so the caller can decide).
|
||||
"""
|
||||
if not is_known_specialist(specialist):
|
||||
raise ValueError(
|
||||
f"Unknown specialist profile: {specialist!r}. "
|
||||
f"Known specialists: {', '.join(list_known_specialists())}"
|
||||
)
|
||||
if not topic or not topic.strip():
|
||||
raise ValueError("topic must be a non-empty string")
|
||||
|
||||
tokens = estimate_handover_tokens(
|
||||
summary=summary,
|
||||
recent_turns=recent_turns,
|
||||
decisions=decisions,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
warn_oversize = False
|
||||
if tokens > max_context_tokens:
|
||||
# We do NOT raise here: the orchestrator may decide to truncate or
|
||||
# accept the cost. We do flag it.
|
||||
warn_oversize = True
|
||||
|
||||
briefing_path = build_briefing_path(project_root, specialist, topic)
|
||||
package = {
|
||||
"specialist": specialist,
|
||||
"topic": topic,
|
||||
"summary": summary.strip(),
|
||||
"recent_turns": list(recent_turns or []),
|
||||
"decisions": list(decisions or []),
|
||||
"artifacts": list(artifacts or []),
|
||||
"return_condition": (return_condition or "user_request").strip(),
|
||||
"handover_reason": (handover_reason or "").strip(),
|
||||
"briefing_file": briefing_path,
|
||||
"estimated_tokens": tokens,
|
||||
"max_context_tokens": max_context_tokens,
|
||||
"oversize_warning": warn_oversize,
|
||||
"_meta": {
|
||||
"created_at": _timestamp_iso(),
|
||||
"phase": "phase-1-readonly",
|
||||
},
|
||||
}
|
||||
return package
|
||||
|
||||
|
||||
def persona_marker(specialist: str) -> str:
|
||||
"""Return a short, UI-friendly persona marker for `specialist`.
|
||||
|
||||
The marker is intentionally compact so it can be prefixed to chat
|
||||
messages without inflating the visible output. Phase 3 may add a richer
|
||||
UI representation (icon, color, etc.).
|
||||
"""
|
||||
safe = specialist.replace("_", " ").strip()
|
||||
return f"[{safe}]"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Registry of help documentation files for this plugin.
|
||||
|
||||
Tools and extensions reference help topics by name; the registry returns
|
||||
the absolute file path that build-skill should inject into the agent's
|
||||
system prompt as agent.system.tool.<tool_name>.md content.
|
||||
|
||||
Per a0-plugin-router SKILL.md: help/<topic>/help.md is NOT auto-loaded.
|
||||
Tools must reference the path explicitly.
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
_PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_HELP_DIR = os.path.join(_PLUGIN_ROOT, "help")
|
||||
|
||||
_TOPICS = {
|
||||
"context_budgeting": "management/context-budgeting.md",
|
||||
"errors": "management/errors.md",
|
||||
"tool_governance": "management/tool-governance.md",
|
||||
"library_query": "library/query.md",
|
||||
"library_extract": "library/extract.md",
|
||||
"runtime": "operations/runtime.md",
|
||||
"deployment": "operations/deployment.md",
|
||||
"git": "operations/git.md",
|
||||
"validation": "testing/validation.md",
|
||||
}
|
||||
|
||||
def list_topics() -> list[str]:
|
||||
return sorted(_TOPICS.keys())
|
||||
|
||||
def get_path(topic: str) -> Optional[str]:
|
||||
rel = _TOPICS.get(topic)
|
||||
if not rel: return None
|
||||
p = os.path.join(_HELP_DIR, rel)
|
||||
return p if os.path.exists(p) else None
|
||||
|
||||
def get_relative_path(topic: str) -> Optional[str]:
|
||||
rel = _TOPICS.get(topic)
|
||||
return f"help/{rel}" if rel else None
|
||||
|
||||
def get_content(topic: str) -> Optional[str]:
|
||||
p = get_path(topic)
|
||||
if not p: return None
|
||||
with open(p) as f: return f.read()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
A0 Software Orchestrator – Patterns-Bibliothek
|
||||
Selbstlernende Wissensdatenbank für Projekt-Patterns.
|
||||
|
||||
Exportiert:
|
||||
- PatternDB: Singleton-Datenbank-Klasse
|
||||
- PatternExtractor: Extrahiert Patterns aus Projekt-Artefakten
|
||||
- extract_from_project: Convenience-Funktion
|
||||
- get_db: Singleton-Zugriff
|
||||
"""
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB, get_db
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.extractor import PatternExtractor, extract_from_project
|
||||
|
||||
__all__ = ['PatternDB', 'PatternExtractor', 'extract_from_project', 'get_db']
|
||||
__version__ = '1.0.0'
|
||||
@@ -0,0 +1,823 @@
|
||||
"""
|
||||
A0 Software Orchestrator – Patterns-Bibliothek
|
||||
Zentrale Datenbank-Klasse mit FTS5, Vektor-Suche, Projekt-Registry,
|
||||
Pattern-Feedback, Konflikt-Management und Aging.
|
||||
|
||||
Verwendung:
|
||||
from library.db import PatternDB
|
||||
db = PatternDB() # Singleton, verwendet Standard-Pfad
|
||||
results = db.search_fts("FastAPI Docker")
|
||||
patterns = db.search_semantic(query_text, top_k=5)
|
||||
projects = db.get_active_projects()
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import hashlib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton PatternDB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PatternDB:
|
||||
"""
|
||||
Singleton-Datenbank-Klasse für die Patterns-Bibliothek.
|
||||
Automatische Initialisierung beim ersten Zugriff.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
DEFAULT_DB_PATH = Path(__file__).parent / "patterns.db"
|
||||
DEFAULT_SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
||||
|
||||
def __new__(cls, db_path: Optional[Path] = None):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
instance = super().__new__(cls)
|
||||
instance._initialized = False
|
||||
cls._instance = instance
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, db_path: Optional[Path] = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self.db_path = Path(db_path) if db_path else self.DEFAULT_DB_PATH
|
||||
self.schema_path = self.DEFAULT_SCHEMA_PATH
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._vec_available: Optional[bool] = None
|
||||
self._embedding_model = None
|
||||
|
||||
# Datenbank initialisieren
|
||||
self._ensure_db()
|
||||
self._initialized = True
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Connection Management
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def conn(self) -> sqlite3.Connection:
|
||||
"""Thread-sichere Connection mit WAL-Mode."""
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
self._conn.execute("PRAGMA cache_size=-64000") # 64 MB Cache
|
||||
self._conn.execute("PRAGMA busy_timeout=5000") # 5 Sekunden Timeout
|
||||
return self._conn
|
||||
|
||||
def _ensure_db(self):
|
||||
"""Stellt sicher, dass die Datenbank existiert und das Schema aktuell ist."""
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self.db_path.exists():
|
||||
# Neue Datenbank: Basisschema ausführen
|
||||
if self.schema_path.exists():
|
||||
schema = self.schema_path.read_text(encoding='utf-8')
|
||||
self.conn.executescript(schema)
|
||||
self.conn.commit()
|
||||
# Neue und existierende DBs immer auf Runtime-Schema migrieren.
|
||||
self._run_migrations()
|
||||
|
||||
def _run_migrations(self):
|
||||
"""Führt ausstehende Schema-Migrationen aus."""
|
||||
cursor = self.conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
# Alte DB ohne schema_version – initialisieren
|
||||
self.conn.executescript(self.schema_path.read_text(encoding='utf-8'))
|
||||
self.conn.commit()
|
||||
# Weitere Migrationen aus migrations.py
|
||||
try:
|
||||
from .migrations import run_migrations
|
||||
run_migrations(self)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""Schließt die Datenbank-Verbindung."""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Projekt-Registry
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def register_project(self, name: str, path: str, tech_stack: Optional[Dict] = None,
|
||||
description: str = "", git_url: str = "") -> int:
|
||||
"""Registriert ein neues Projekt oder aktualisiert ein bestehendes.
|
||||
|
||||
ARCHITEKTUR-NOTE (Bugfix-Auto-Registration §3.6 / Fix 4 Befund):
|
||||
Diese Methode gehört zum DB-Layer (helpers/library/db.py), NICHT zum
|
||||
Project-Layer (helpers/db_state_store.py). Sie wird derzeit NUR von
|
||||
extractor.py (Pattern-Extraktion aus Repo-Verzeichnissen) aufgerufen,
|
||||
mit Namen aus `project_path` (Verzeichnisname). User-facing Project-
|
||||
Registrierung läuft über `db_state_store.register_project()`, das seit
|
||||
Plan v3 §3.1 Pattern+Blacklist validiert.
|
||||
|
||||
Daher: KEINE Plausi-Prüfung hier. Bewusst out-of-scope, weil:
|
||||
1. Einziger Caller ist ein internes Library-Tool (extractor.py).
|
||||
2. extractor.py nutzt Verzeichnisnamen, die bereits durch
|
||||
project_path-Lookup semi-kontrolliert sind.
|
||||
3. Plan v3 hat DB-Layer-Plausi explizit als separater Fix markiert.
|
||||
|
||||
Wenn ein neuer Caller mit user-input-Namen diese Methode aufruft,
|
||||
MUSS er Pattern+Blacklist-Prüfung VORAB durchführen (siehe
|
||||
`db_state_store._validate_project_name`).
|
||||
"""
|
||||
tech_json = json.dumps(tech_stack) if tech_stack is not None else "{}"
|
||||
cur = self.conn.execute("""
|
||||
INSERT INTO projects (name, project_path, git_url, tech_stack, description)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
project_path = excluded.project_path,
|
||||
git_url = excluded.git_url,
|
||||
tech_stack = excluded.tech_stack,
|
||||
description = excluded.description
|
||||
""", (name, path, git_url, tech_json, description))
|
||||
row = self.conn.execute("SELECT id FROM projects WHERE name = ?", (name,)).fetchone()
|
||||
project_id = int(row[0] if row else cur.lastrowid)
|
||||
self.conn.execute("""
|
||||
INSERT INTO project_state (project_id, status, phase, last_active_at)
|
||||
VALUES (?, 'active', 'intake', datetime('now'))
|
||||
ON CONFLICT(project_id) DO UPDATE SET last_active_at = datetime('now')
|
||||
""", (project_id,))
|
||||
self.conn.commit()
|
||||
return project_id
|
||||
|
||||
def update_project_phase(self, name: str, phase: str, plan_mode: str = None):
|
||||
"""Aktualisiert Phase und Plan-Mode eines Projekts."""
|
||||
project_id = self.register_project(name, "", description="auto-created by PatternDB")
|
||||
if plan_mode:
|
||||
self.conn.execute("""
|
||||
UPDATE project_state
|
||||
SET phase = ?, plan_mode = ?, last_active_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE project_id = ?
|
||||
""", (phase, plan_mode, project_id))
|
||||
else:
|
||||
self.conn.execute("""
|
||||
UPDATE project_state
|
||||
SET phase = ?, last_active_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE project_id = ?
|
||||
""", (phase, project_id))
|
||||
self.conn.commit()
|
||||
|
||||
def update_project_metrics(self, name: str, total_tasks: int = None,
|
||||
completed_tasks: int = None, open_errors: int = None):
|
||||
"""Aktualisiert die Projekt-Metriken."""
|
||||
project_id = self.register_project(name, "", description="auto-created by PatternDB")
|
||||
updates = []
|
||||
params: List[Any] = []
|
||||
if total_tasks is not None:
|
||||
updates.append("total_tasks = ?"); params.append(total_tasks)
|
||||
if completed_tasks is not None:
|
||||
updates.append("completed_tasks = ?"); params.append(completed_tasks)
|
||||
if open_errors is not None:
|
||||
updates.append("open_errors = ?"); params.append(open_errors)
|
||||
if updates:
|
||||
updates.append("last_active_at = datetime('now')")
|
||||
updates.append("updated_at = datetime('now')")
|
||||
params.append(project_id)
|
||||
self.conn.execute(f"UPDATE project_state SET {', '.join(updates)} WHERE project_id = ?", params)
|
||||
self.conn.commit()
|
||||
|
||||
def get_active_projects(self) -> List[sqlite3.Row]:
|
||||
"""Alle aktiven Projekte."""
|
||||
return self.conn.execute("""
|
||||
SELECT p.*, ps.status, ps.phase, ps.plan_mode, ps.total_tasks,
|
||||
ps.completed_tasks, ps.open_errors, ps.last_active_at, ps.completed_at
|
||||
FROM projects p
|
||||
JOIN project_state ps ON ps.project_id = p.id
|
||||
WHERE ps.status = 'active'
|
||||
ORDER BY COALESCE(ps.last_active_at, p.created_at) DESC
|
||||
""").fetchall()
|
||||
|
||||
def get_project_summary(self, project_name: str) -> Optional[sqlite3.Row]:
|
||||
"""Kurzübersicht eines Projekts."""
|
||||
return self.conn.execute("""
|
||||
SELECT p.name AS project_name, ps.status, ps.phase, ps.plan_mode,
|
||||
ps.completed_tasks || '/' || ps.total_tasks AS progress,
|
||||
ps.open_errors, COALESCE(p.patterns_extracted, 0) AS patterns_extracted,
|
||||
p.tech_stack, ps.last_active_at, p.description
|
||||
FROM projects p
|
||||
JOIN project_state ps ON ps.project_id = p.id
|
||||
WHERE p.name = ?
|
||||
""", (project_name,)).fetchone()
|
||||
|
||||
def get_projects_by_tech(self, tech: str) -> List[sqlite3.Row]:
|
||||
"""Alle Projekte mit bestimmter Technologie."""
|
||||
return self.conn.execute("""
|
||||
SELECT p.*, ps.status, ps.phase, ps.plan_mode
|
||||
FROM projects p
|
||||
JOIN project_state ps ON ps.project_id = p.id
|
||||
WHERE p.tech_stack LIKE ?
|
||||
ORDER BY p.name
|
||||
""", (f'%{tech}%',)).fetchall()
|
||||
|
||||
def get_orphaned_projects(self, days: int = 7) -> List[sqlite3.Row]:
|
||||
"""Projekte, die >N Tage nicht aktiv waren."""
|
||||
threshold = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
||||
return self.conn.execute("""
|
||||
SELECT p.*, ps.status, ps.phase, ps.plan_mode, ps.last_active_at
|
||||
FROM projects p
|
||||
JOIN project_state ps ON ps.project_id = p.id
|
||||
WHERE ps.status = 'active'
|
||||
AND COALESCE(ps.last_active_at, p.created_at) < ?
|
||||
""", (threshold,)).fetchall()
|
||||
|
||||
def set_project_status(self, name: str, status: str, notes: str = ""):
|
||||
"""Setzt den Projekt-Status (active, paused, completed, archived, failed)."""
|
||||
project_id = self.register_project(name, "", description=notes or "auto-created by PatternDB")
|
||||
if status == 'completed':
|
||||
self.conn.execute("""
|
||||
UPDATE project_state
|
||||
SET status = ?, completed_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE project_id = ?
|
||||
""", (status, project_id))
|
||||
else:
|
||||
self.conn.execute("""
|
||||
UPDATE project_state
|
||||
SET status = ?, last_active_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE project_id = ?
|
||||
""", (status, project_id))
|
||||
if notes:
|
||||
self.conn.execute("UPDATE projects SET description = ? WHERE id = ?", (notes, project_id))
|
||||
self.conn.commit()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Patterns CRUD
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def add_pattern(self, title: str, category: str, pattern_type: str,
|
||||
description: str, **kwargs) -> int:
|
||||
"""
|
||||
Fügt ein neues Pattern hinzu.
|
||||
|
||||
Args:
|
||||
title: Kurztitel
|
||||
category: docker, python, frontend, etc.
|
||||
pattern_type: code_snippet, error_solution, etc.
|
||||
description: Beschreibung
|
||||
**kwargs: subcategory, code_example, when_to_use, why_it_works,
|
||||
pitfalls, source_project_id, source_task_id, source_error,
|
||||
source_file, framework_version, tags (Liste),
|
||||
validated (0=auto, 1=reviewed, 2=manual)
|
||||
|
||||
Returns:
|
||||
int: ID des neuen Patterns
|
||||
"""
|
||||
tags = kwargs.pop('tags', [])
|
||||
|
||||
columns = ['title', 'category', 'pattern_type', 'description']
|
||||
values = [title, category, pattern_type, description]
|
||||
|
||||
allowed_kwargs = [
|
||||
'subcategory', 'code_example', 'when_to_use', 'why_it_works',
|
||||
'pitfalls', 'source_project_id', 'source_task_id', 'source_error',
|
||||
'source_file', 'framework_version', 'validated'
|
||||
]
|
||||
|
||||
for key in allowed_kwargs:
|
||||
if key in kwargs and kwargs[key] is not None:
|
||||
columns.append(key)
|
||||
values.append(kwargs[key])
|
||||
|
||||
placeholders = ', '.join(['?'] * len(columns))
|
||||
columns_str = ', '.join(columns)
|
||||
|
||||
cursor = self.conn.execute(
|
||||
f"INSERT INTO patterns ({columns_str}) VALUES ({placeholders})",
|
||||
values
|
||||
)
|
||||
pattern_id = cursor.lastrowid
|
||||
|
||||
# Tags hinzufügen
|
||||
for tag_name in tags:
|
||||
self._add_tag(pattern_id, tag_name)
|
||||
|
||||
self.conn.commit()
|
||||
return pattern_id
|
||||
|
||||
def update_pattern(self, pattern_id: int, **kwargs) -> bool:
|
||||
"""Aktualisiert ein bestehendes Pattern."""
|
||||
allowed = [
|
||||
'title', 'category', 'subcategory', 'pattern_type', 'description',
|
||||
'code_example', 'when_to_use', 'why_it_works', 'pitfalls',
|
||||
'framework_version', 'validated', 'validation_date', 'validated_by'
|
||||
]
|
||||
|
||||
updates = []
|
||||
params: List[Any] = []
|
||||
|
||||
for key in allowed:
|
||||
if key in kwargs:
|
||||
updates.append(f"{key} = ?")
|
||||
params.append(kwargs[key])
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
updates.append("updated_at = datetime('now')")
|
||||
params.append(pattern_id)
|
||||
|
||||
self.conn.execute(
|
||||
f"UPDATE patterns SET {', '.join(updates)} WHERE id = ?",
|
||||
params
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# Tags aktualisieren
|
||||
if 'tags' in kwargs:
|
||||
self.conn.execute("DELETE FROM pattern_tags WHERE pattern_id = ?", (pattern_id,))
|
||||
for tag_name in kwargs['tags']:
|
||||
self._add_tag(pattern_id, tag_name)
|
||||
|
||||
return True
|
||||
|
||||
def get_pattern(self, pattern_id: int) -> Optional[sqlite3.Row]:
|
||||
"""Lädt ein einzelnes Pattern mit allen Details."""
|
||||
return self.conn.execute(
|
||||
"SELECT * FROM patterns WHERE id = ?", (pattern_id,)
|
||||
).fetchone()
|
||||
|
||||
def get_patterns_by_category(self, category: str, pattern_type: str = None,
|
||||
validated_only: bool = True,
|
||||
limit: int = 20) -> List[sqlite3.Row]:
|
||||
"""Patterns nach Kategorie filtern."""
|
||||
sql = "SELECT * FROM patterns WHERE category = ?"
|
||||
params: List[Any] = [category]
|
||||
|
||||
if pattern_type:
|
||||
sql += " AND pattern_type = ?"
|
||||
params.append(pattern_type)
|
||||
if validated_only:
|
||||
sql += " AND validated >= 0"
|
||||
|
||||
sql += " ORDER BY usage_count DESC, success_rate DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
return self.conn.execute(sql, params).fetchall()
|
||||
|
||||
def get_pattern_tags(self, pattern_id: int) -> List[str]:
|
||||
"""Alle Tags eines Patterns."""
|
||||
rows = self.conn.execute("""
|
||||
SELECT t.name FROM tags t
|
||||
JOIN pattern_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.pattern_id = ?
|
||||
""", (pattern_id,)).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def _add_tag(self, pattern_id: int, tag_name: str):
|
||||
"""Fügt einen Tag hinzu und verknüpft ihn mit einem Pattern."""
|
||||
self.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
|
||||
tag_id = self.conn.execute(
|
||||
"SELECT id FROM tags WHERE name = ?", (tag_name,)
|
||||
).fetchone()[0]
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO pattern_tags (pattern_id, tag_id) VALUES (?, ?)",
|
||||
(pattern_id, tag_id)
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# FTS5 Volltextsuche
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def search_fts(self, query: str, limit: int = 10,
|
||||
category: str = None, pattern_type: str = None,
|
||||
validated_only: bool = True) -> List[sqlite3.Row]:
|
||||
"""
|
||||
Volltextsuche mit FTS5.
|
||||
|
||||
Args:
|
||||
query: Suchbegriffe (FTS5-Syntax: "FastAPI Docker", "error AND fix", etc.)
|
||||
limit: Maximale Ergebnisse
|
||||
category: Optional, nach Kategorie filtern
|
||||
pattern_type: Optional, nach Typ filtern
|
||||
validated_only: Nur validierte Patterns (>= 0)
|
||||
|
||||
Returns:
|
||||
Liste von Pattern-Rows mit rank-Spalte
|
||||
"""
|
||||
# FTS5-Abfrage vorbereiten (Wildcards für Teilwortsuche)
|
||||
fts_query = ' OR '.join(f'"{term}"*' for term in query.split())
|
||||
|
||||
sql = """
|
||||
SELECT p.*, rank
|
||||
FROM patterns_fts
|
||||
JOIN patterns p ON patterns_fts.rowid = p.id
|
||||
WHERE patterns_fts MATCH ?
|
||||
"""
|
||||
params: List[Any] = [fts_query]
|
||||
|
||||
if category:
|
||||
sql += " AND p.category = ?"
|
||||
params.append(category)
|
||||
if pattern_type:
|
||||
sql += " AND p.pattern_type = ?"
|
||||
params.append(pattern_type)
|
||||
if validated_only:
|
||||
sql += " AND p.validated >= 0"
|
||||
|
||||
sql += " ORDER BY rank LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
return self.conn.execute(sql, params).fetchall()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Vektor-Suche (sqlite-vec)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def vec_available(self) -> bool:
|
||||
"""Prüft, ob sqlite-vec verfügbar ist."""
|
||||
if self._vec_available is None:
|
||||
try:
|
||||
import sqlite_vec
|
||||
self._vec_available = True
|
||||
self._init_vec_table()
|
||||
except ImportError:
|
||||
self._vec_available = False
|
||||
return self._vec_available
|
||||
|
||||
def _init_vec_table(self):
|
||||
"""Initialisiert die Vektor-Tabelle, wenn sqlite-vec vorhanden ist."""
|
||||
try:
|
||||
import sqlite_vec
|
||||
self.conn.enable_load_extension(True)
|
||||
sqlite_vec.load(self.conn)
|
||||
|
||||
self.conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS pattern_embeddings USING vec0(
|
||||
embedding float[384]
|
||||
)
|
||||
""")
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
print(f"[PatternDB] sqlite-vec konnte nicht initialisiert werden: {e}")
|
||||
self._vec_available = False
|
||||
|
||||
def _get_embedding_model(self):
|
||||
"""Lädt das Embedding-Modell (lazy, nur wenn benötigt)."""
|
||||
if self._embedding_model is None:
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self._embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
|
||||
except ImportError:
|
||||
print("[PatternDB] sentence-transformers nicht installiert. "
|
||||
"Vektor-Suche nicht verfügbar.")
|
||||
self._vec_available = False
|
||||
return self._embedding_model
|
||||
|
||||
def store_embedding(self, pattern_id: int, text: str):
|
||||
"""
|
||||
Speichert das Embedding eines Patterns für die Vektor-Suche.
|
||||
|
||||
Args:
|
||||
pattern_id: ID des Patterns
|
||||
text: Text zum Embedden (description + code_example)
|
||||
"""
|
||||
if not self.vec_available:
|
||||
return
|
||||
|
||||
model = self._get_embedding_model()
|
||||
if model is None:
|
||||
return
|
||||
|
||||
embedding = model.encode(text)
|
||||
|
||||
# Bestehendes Embedding löschen (vec0 hat keine UPDATE-Logik)
|
||||
self.conn.execute(
|
||||
"DELETE FROM pattern_embeddings WHERE rowid = ?", (pattern_id,)
|
||||
)
|
||||
self.conn.execute(
|
||||
"INSERT INTO pattern_embeddings (rowid, embedding) VALUES (?, ?)",
|
||||
(pattern_id, embedding.tobytes())
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def search_semantic(self, query: str, top_k: int = 10,
|
||||
category: str = None, pattern_type: str = None,
|
||||
validated_only: bool = True) -> List[Tuple[sqlite3.Row, float]]:
|
||||
"""
|
||||
Semantische Vektor-Suche. Fallback auf FTS5, wenn sqlite-vec nicht verfügbar.
|
||||
|
||||
Args:
|
||||
query: Natürlichsprachliche Suchanfrage
|
||||
top_k: Anzahl Ergebnisse
|
||||
category: Optional, Kategorie-Filter
|
||||
pattern_type: Optional, Typ-Filter
|
||||
validated_only: Nur validierte Patterns
|
||||
|
||||
Returns:
|
||||
Liste von (Pattern-Row, similarity_score) Tupeln, absteigend nach Ähnlichkeit
|
||||
"""
|
||||
if not self.vec_available:
|
||||
# Fallback auf FTS5
|
||||
rows = self.search_fts(query, limit=top_k, category=category,
|
||||
pattern_type=pattern_type, validated_only=validated_only)
|
||||
return [(r, -r['rank']) for r in rows] # rank ist negativ, invertieren
|
||||
|
||||
model = self._get_embedding_model()
|
||||
if model is None:
|
||||
rows = self.search_fts(query, limit=top_k, category=category,
|
||||
pattern_type=pattern_type, validated_only=validated_only)
|
||||
return [(r, -r['rank']) for r in rows]
|
||||
|
||||
# Query embedden
|
||||
query_embedding = model.encode(query)
|
||||
|
||||
# KNN-Suche
|
||||
categories_where = ""
|
||||
if category:
|
||||
categories_where = f"AND p.category = '{category}'"
|
||||
if pattern_type:
|
||||
categories_where += f" AND p.pattern_type = '{pattern_type}'"
|
||||
if validated_only:
|
||||
categories_where += " AND p.validated >= 0"
|
||||
|
||||
sql = f"""
|
||||
SELECT p.*, vec_distance_cosine(pe.embedding, ?) AS distance
|
||||
FROM pattern_embeddings pe
|
||||
JOIN patterns p ON pe.rowid = p.id
|
||||
WHERE 1=1 {categories_where}
|
||||
ORDER BY distance ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
rows = self.conn.execute(sql, (query_embedding.tobytes(), top_k)).fetchall()
|
||||
|
||||
# Distance in Similarity umrechnen (1 - distance)
|
||||
return [(r, 1.0 - r['distance']) for r in rows]
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Pattern-Feedback & Lernen
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def add_feedback(self, pattern_id: int, project_id: int, outcome: str,
|
||||
reason: str = "", context_diff: str = "",
|
||||
alternative_used: str = "",
|
||||
alternative_pattern_id: int = None) -> int:
|
||||
"""
|
||||
Speichert Feedback zu einem Pattern (positiv oder negativ).
|
||||
Aktualisiert automatisch die Pattern-Statistiken.
|
||||
"""
|
||||
feedback_id = self.conn.execute("""
|
||||
INSERT INTO pattern_feedback
|
||||
(pattern_id, project_id, outcome, reason, context_diff,
|
||||
alternative_used, alternative_pattern_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (pattern_id, project_id, outcome, reason, context_diff,
|
||||
alternative_used, alternative_pattern_id)).lastrowid
|
||||
|
||||
# Pattern-Statistiken aktualisieren
|
||||
if outcome == 'success':
|
||||
self.conn.execute(
|
||||
"UPDATE patterns SET success_count = success_count + 1, usage_count = usage_count + 1, last_used_at = datetime('now') WHERE id = ?",
|
||||
(pattern_id,)
|
||||
)
|
||||
elif outcome == 'failure':
|
||||
self.conn.execute(
|
||||
"UPDATE patterns SET failure_count = failure_count + 1, usage_count = usage_count + 1, last_used_at = datetime('now') WHERE id = ?",
|
||||
(pattern_id,)
|
||||
)
|
||||
elif outcome == 'partial_success':
|
||||
self.conn.execute(
|
||||
"UPDATE patterns SET success_count = success_count + 1, failure_count = failure_count + 1, usage_count = usage_count + 1, last_used_at = datetime('now') WHERE id = ?",
|
||||
(pattern_id,)
|
||||
)
|
||||
|
||||
# Success-Rate neu berechnen
|
||||
self.conn.execute("""
|
||||
UPDATE patterns
|
||||
SET success_rate = CASE
|
||||
WHEN success_count + failure_count > 0
|
||||
THEN CAST(success_count AS REAL) / (success_count + failure_count)
|
||||
ELSE 0.0
|
||||
END
|
||||
WHERE id = ?
|
||||
""", (pattern_id,))
|
||||
|
||||
self.conn.commit()
|
||||
return feedback_id
|
||||
|
||||
def record_usage(self, pattern_id: int):
|
||||
"""Vermerkt, dass ein Pattern verwendet wurde (ohne Erfolg/Misserfolg)."""
|
||||
self.conn.execute(
|
||||
"UPDATE patterns SET usage_count = usage_count + 1, last_used_at = datetime('now') WHERE id = ?",
|
||||
(pattern_id,)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Pattern-Konflikte
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def add_conflict(self, pattern_a_id: int, pattern_b_id: int,
|
||||
conflict_type: str, description: str = "",
|
||||
resolution_context: str = "") -> int:
|
||||
"""Registriert einen Konflikt zwischen zwei Patterns."""
|
||||
return self.conn.execute("""
|
||||
INSERT OR IGNORE INTO pattern_conflicts
|
||||
(pattern_a_id, pattern_b_id, conflict_type, description, resolution_context)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (pattern_a_id, pattern_b_id, conflict_type, description, resolution_context)).lastrowid
|
||||
|
||||
def get_conflicts(self) -> List[sqlite3.Row]:
|
||||
"""Alle ungelösten Konflikte."""
|
||||
return self.conn.execute("""
|
||||
SELECT * FROM v_conflicts WHERE resolved_by = 'system'
|
||||
""").fetchall()
|
||||
|
||||
def resolve_conflict(self, conflict_id: int, resolution: str, resolved_by: str = "quality_reviewer"):
|
||||
"""Löst einen Pattern-Konflikt auf."""
|
||||
self.conn.execute("""
|
||||
UPDATE pattern_conflicts
|
||||
SET resolution_context = ?, resolved_by = ?, resolved_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""", (resolution, resolved_by, conflict_id))
|
||||
self.conn.commit()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Pattern-Aging
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def update_staleness_scores(self):
|
||||
"""
|
||||
Berechnet den staleness_score für alle Patterns neu.
|
||||
|
||||
Faktoren:
|
||||
- Alter (Tage seit Erstellung): 0-100 → 0.0-0.4
|
||||
- Letzte Verwendung (Tage): 0-365 → 0.0-0.3
|
||||
- Letzte Validierung (Tage): 0-365 → 0.0-0.2
|
||||
- Framework-Version vorhanden? nein → +0.1
|
||||
"""
|
||||
self.conn.execute("""
|
||||
UPDATE patterns SET staleness_score = (
|
||||
MIN(1.0,
|
||||
-- Alter-Faktor: 0.4 nach 365 Tagen
|
||||
(julianday('now') - julianday(created_at)) / 365.0 * 0.4 +
|
||||
-- Letzte-Verwendung-Faktor: 0.3 nach 365 Tagen
|
||||
CASE WHEN last_used_at IS NOT NULL
|
||||
THEN (julianday('now') - julianday(last_used_at)) / 365.0 * 0.3
|
||||
ELSE 0.3 -- Nie verwendet = voller Abzug
|
||||
END +
|
||||
-- Validierungs-Faktor: 0.2 nach 365 Tagen
|
||||
CASE WHEN validation_date IS NOT NULL
|
||||
THEN (julianday('now') - julianday(validation_date)) / 365.0 * 0.2
|
||||
ELSE 0.2 -- Nie validiert = voller Abzug
|
||||
END +
|
||||
-- Framework-Version fehlt: +0.1
|
||||
CASE WHEN framework_version IS NULL OR framework_version = ''
|
||||
THEN 0.1 ELSE 0.0 END
|
||||
)
|
||||
)
|
||||
WHERE validated >= 0
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
def deprecate_stale_patterns(self, threshold: float = 0.7):
|
||||
"""Markiert stark veraltete Patterns als deprecated (validated = -1)."""
|
||||
self.conn.execute("""
|
||||
UPDATE patterns SET validated = -1, updated_at = datetime('now')
|
||||
WHERE staleness_score >= ? AND validated >= 0
|
||||
""", (threshold,))
|
||||
self.conn.commit()
|
||||
|
||||
def get_stale_patterns(self, threshold: float = 0.5) -> List[sqlite3.Row]:
|
||||
"""Patterns, die zu veralten drohen."""
|
||||
return self.conn.execute("""
|
||||
SELECT * FROM patterns
|
||||
WHERE staleness_score >= ? AND validated >= 0
|
||||
ORDER BY staleness_score DESC
|
||||
""", (threshold,)).fetchall()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Relationen
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def add_relation(self, pattern_a_id: int, pattern_b_id: int,
|
||||
relation_type: str, strength: float = 1.0, notes: str = ""):
|
||||
"""Fügt eine Beziehung zwischen zwei Patterns hinzu."""
|
||||
self.conn.execute("""
|
||||
INSERT OR IGNORE INTO pattern_relations
|
||||
(pattern_a_id, pattern_b_id, relation_type, strength, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (pattern_a_id, pattern_b_id, relation_type, strength, notes))
|
||||
self.conn.commit()
|
||||
|
||||
def get_related_patterns(self, pattern_id: int,
|
||||
relation_type: str = None) -> List[sqlite3.Row]:
|
||||
"""Verwandte Patterns eines Patterns."""
|
||||
sql = """
|
||||
SELECT p.*, pr.relation_type, pr.strength
|
||||
FROM pattern_relations pr
|
||||
JOIN patterns p ON (
|
||||
CASE WHEN pr.pattern_a_id = ? THEN pr.pattern_b_id = p.id
|
||||
ELSE pr.pattern_a_id = p.id END
|
||||
)
|
||||
WHERE (pr.pattern_a_id = ? OR pr.pattern_b_id = ?)
|
||||
"""
|
||||
params: List[Any] = [pattern_id, pattern_id, pattern_id]
|
||||
|
||||
if relation_type:
|
||||
sql += " AND pr.relation_type = ?"
|
||||
params.append(relation_type)
|
||||
|
||||
return self.conn.execute(sql, params).fetchall()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lern-Log
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def log_learning(self, pattern_id: int, project_id: int, trigger: str,
|
||||
source_file: str = "", source_content: str = ""):
|
||||
"""Protokolliert einen Lern-Vorgang."""
|
||||
content_hash = hashlib.md5(source_content.encode()).hexdigest() if source_content else None
|
||||
self.conn.execute("""
|
||||
INSERT INTO learn_log (pattern_id, project_id, trigger, source_file, source_content_hash)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (pattern_id, project_id, trigger, source_file, content_hash))
|
||||
self.conn.commit()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Backup
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def backup(self, backup_path: Path = None) -> Path:
|
||||
"""Erstellt ein Backup der Datenbank."""
|
||||
if backup_path is None:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = self.db_path.parent / f"patterns_backup_{timestamp}.db"
|
||||
|
||||
# SQLite-Backup via Backup-API
|
||||
backup_conn = sqlite3.connect(str(backup_path))
|
||||
self.conn.backup(backup_conn)
|
||||
backup_conn.close()
|
||||
|
||||
return backup_path
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Statistik & Reporting
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Gesamtstatistik der Bibliothek."""
|
||||
total_patterns = self.conn.execute("SELECT COUNT(*) FROM patterns").fetchone()[0]
|
||||
validated = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM patterns WHERE validated >= 0"
|
||||
).fetchone()[0]
|
||||
deprecated = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM patterns WHERE validated = -1"
|
||||
).fetchone()[0]
|
||||
unvalidated = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM patterns WHERE validated = 0"
|
||||
).fetchone()[0]
|
||||
|
||||
categories = self.conn.execute("""
|
||||
SELECT category, COUNT(*) as cnt FROM patterns
|
||||
WHERE validated >= 0 GROUP BY category ORDER BY cnt DESC
|
||||
""").fetchall()
|
||||
|
||||
active_projects = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM project_state WHERE status = 'active'"
|
||||
).fetchone()[0]
|
||||
|
||||
total_feedback = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM pattern_feedback"
|
||||
).fetchone()[0]
|
||||
|
||||
avg_success_rate = self.conn.execute("""
|
||||
SELECT AVG(success_rate) FROM patterns WHERE usage_count > 0 AND validated >= 0
|
||||
""").fetchone()[0] or 0.0
|
||||
|
||||
return {
|
||||
'total_patterns': total_patterns,
|
||||
'validated_patterns': validated,
|
||||
'unvalidated_patterns': unvalidated,
|
||||
'deprecated_patterns': deprecated,
|
||||
'active_projects': active_projects,
|
||||
'total_feedback_entries': total_feedback,
|
||||
'average_success_rate': round(avg_success_rate * 100, 1),
|
||||
'categories': {r['category']: r['cnt'] for r in categories},
|
||||
'vec_available': self.vec_available,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience-Funktionen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_db() -> PatternDB:
|
||||
"""Singleton-Zugriff."""
|
||||
return PatternDB()
|
||||
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
A0 Software Orchestrator – Pattern-Extraktor
|
||||
Extrahiert Patterns aus abgeschlossenen Projekt-Artefakten und speichert sie
|
||||
in der Patterns-Datenbank.
|
||||
|
||||
Kontinuierliche Trigger:
|
||||
- error_fixed: known_errors.md wurde aktualisiert
|
||||
- task_completed: Task in task_graph.json als 'done' markiert
|
||||
- deploy_ok: runtime_report.md zeigt Erfolg
|
||||
- decision_made: decisions.md neuer Eintrag
|
||||
- release_done: Release Audit abgeschlossen
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB, get_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraktor-Klasse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PatternExtractor:
|
||||
"""
|
||||
Extrahiert Patterns aus Projekt-Artefakten.
|
||||
|
||||
Verwendung:
|
||||
extractor = PatternExtractor(project_path)
|
||||
patterns = extractor.extract_from_known_errors()
|
||||
patterns += extractor.extract_from_decisions()
|
||||
patterns += extractor.extract_from_architecture()
|
||||
patterns += extractor.extract_from_docker()
|
||||
patterns += extractor.extract_from_deployment()
|
||||
"""
|
||||
|
||||
def __init__(self, project_path: Path, db: PatternDB = None):
|
||||
self.project_path = Path(project_path)
|
||||
self.a0_path = self.project_path / ".a0"
|
||||
self.db = db or get_db()
|
||||
self.project_name = self.project_path.name
|
||||
self.project_id = None
|
||||
|
||||
# Projekt in Registry finden/registrieren
|
||||
self._ensure_project_registered()
|
||||
|
||||
def _ensure_project_registered(self):
|
||||
"""Stellt sicher, dass das Projekt in der Registry ist."""
|
||||
existing = self.db.conn.execute(
|
||||
"SELECT id FROM projects WHERE project_path = ? OR name = ?",
|
||||
(str(self.project_path), self.project_name)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
self.project_id = existing[0]
|
||||
else:
|
||||
# Tech-Stack aus Projekt-Artefakten erkennen
|
||||
tech_stack = self._detect_tech_stack()
|
||||
self.project_id = self.db.register_project(
|
||||
name=self.project_name,
|
||||
path=str(self.project_path),
|
||||
tech_stack=tech_stack,
|
||||
description=f"Automatisch registriert am {datetime.now().isoformat()}"
|
||||
)
|
||||
|
||||
def _detect_tech_stack(self) -> Dict[str, Any]:
|
||||
"""Erkennt den Tech-Stack aus Projekt-Dateien."""
|
||||
stack = {}
|
||||
|
||||
# Python
|
||||
if (self.project_path / "requirements.txt").exists():
|
||||
content = (self.project_path / "requirements.txt").read_text()
|
||||
if 'fastapi' in content.lower():
|
||||
stack['backend'] = 'FastAPI'
|
||||
elif 'flask' in content.lower():
|
||||
stack['backend'] = 'Flask'
|
||||
elif 'django' in content.lower():
|
||||
stack['backend'] = 'Django'
|
||||
if 'sqlalchemy' in content.lower():
|
||||
stack['orm'] = 'SQLAlchemy'
|
||||
if 'pydantic' in content.lower():
|
||||
stack['validation'] = 'Pydantic'
|
||||
if 'alembic' in content.lower():
|
||||
stack['migrations'] = 'Alembic'
|
||||
|
||||
# Node/Frontend
|
||||
if (self.project_path / "package.json").exists():
|
||||
try:
|
||||
pkg = json.loads((self.project_path / "package.json").read_text())
|
||||
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
|
||||
if 'react' in deps:
|
||||
stack['frontend'] = 'React'
|
||||
if 'next' in deps:
|
||||
stack['frontend'] = 'Next.js'
|
||||
if 'vue' in deps:
|
||||
stack['frontend'] = 'Vue'
|
||||
if 'typescript' in deps:
|
||||
stack['language'] = 'TypeScript'
|
||||
if 'vite' in deps:
|
||||
stack['bundler'] = 'Vite'
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Docker
|
||||
if (self.project_path / "Dockerfile").exists():
|
||||
stack['docker'] = True
|
||||
if (self.project_path / "docker-compose.yml").exists():
|
||||
stack['docker_compose'] = True
|
||||
|
||||
# Datenbank aus env.md oder config
|
||||
env_md = self.a0_path / "env.md"
|
||||
if env_md.exists():
|
||||
content = env_md.read_text().lower()
|
||||
if 'postgresql' in content or 'postgres' in content:
|
||||
stack['db'] = 'PostgreSQL'
|
||||
elif 'mysql' in content:
|
||||
stack['db'] = 'MySQL'
|
||||
elif 'sqlite' in content:
|
||||
stack['db'] = 'SQLite'
|
||||
|
||||
return stack
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Extraktoren für verschiedene Artefakte
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def extract_from_known_errors(self) -> List[int]:
|
||||
"""
|
||||
Extrahiert error_solution-Patterns aus known_errors.md.
|
||||
Nur Einträge, die eine Lösung enthalten (nicht nur Beschreibung).
|
||||
"""
|
||||
errors_file = self.a0_path / "known_errors.md"
|
||||
if not errors_file.exists():
|
||||
return []
|
||||
|
||||
content = errors_file.read_text(encoding='utf-8')
|
||||
content_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
# Prüfen, ob diese Datei bereits extrahiert wurde
|
||||
already = self.db.conn.execute(
|
||||
"SELECT id FROM learn_log WHERE source_file = ? AND source_content_hash = ?",
|
||||
(str(errors_file), content_hash)
|
||||
).fetchone()
|
||||
if already:
|
||||
return [] # Keine Änderungen seit letzter Extraktion
|
||||
|
||||
pattern_ids = []
|
||||
|
||||
# Einfache Heuristik: Nach "## Error:" oder "### Lösung:" Blöcken suchen
|
||||
# Block-Split an Doppel-Newlines
|
||||
blocks = content.split('\n\n')
|
||||
|
||||
for block in blocks:
|
||||
block = block.strip()
|
||||
if not block or len(block) < 20:
|
||||
continue
|
||||
|
||||
# Nur Blöcke mit Lösung extrahieren
|
||||
has_solution = any(kw in block.lower() for kw in ['lösung', 'solution', 'fix', 'behoben', 'resolved'])
|
||||
if not has_solution:
|
||||
continue
|
||||
|
||||
# Titel aus erster Zeile
|
||||
lines = block.split('\n')
|
||||
title = lines[0].lstrip('#').strip()[:100]
|
||||
if not title:
|
||||
title = block[:80] + '...' if len(block) > 80 else block
|
||||
|
||||
# Pattern speichern
|
||||
pid = self.db.add_pattern(
|
||||
title=title,
|
||||
category='error_fix',
|
||||
pattern_type='error_solution',
|
||||
description=block[:500],
|
||||
code_example=self._extract_code_block(block),
|
||||
source_project_id=self.project_id,
|
||||
source_file=str(errors_file),
|
||||
source_error=title,
|
||||
validated=0 # Automatisch extrahiert → ungeprüft
|
||||
)
|
||||
|
||||
# Embedding speichern (für Vektor-Suche)
|
||||
self.db.store_embedding(pid, block[:1000])
|
||||
|
||||
# Lern-Log
|
||||
self.db.log_learning(pid, self.project_id, 'error_fixed',
|
||||
str(errors_file), content)
|
||||
|
||||
pattern_ids.append(pid)
|
||||
|
||||
return pattern_ids
|
||||
|
||||
def extract_from_decisions(self) -> List[int]:
|
||||
"""
|
||||
Extrahiert architecture_decision-Patterns aus decisions.md.
|
||||
"""
|
||||
decisions_file = self.a0_path / "decisions.md"
|
||||
if not decisions_file.exists():
|
||||
return []
|
||||
|
||||
content = decisions_file.read_text(encoding='utf-8')
|
||||
content_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
already = self.db.conn.execute(
|
||||
"SELECT id FROM learn_log WHERE source_file = ? AND source_content_hash = ?",
|
||||
(str(decisions_file), content_hash)
|
||||
).fetchone()
|
||||
if already:
|
||||
return []
|
||||
|
||||
pattern_ids = []
|
||||
|
||||
# ADR-Blöcke: ## ADR-001: Titel
|
||||
adr_blocks = re.split(r'\n## ADR-\d+:', content)[1:] # Skip header
|
||||
|
||||
for i, block in enumerate(adr_blocks):
|
||||
lines = block.strip().split('\n')
|
||||
title = lines[0].strip() if lines else f"ADR-{i+1}"
|
||||
|
||||
# Entscheidung + Begründung extrahieren
|
||||
decision = ""
|
||||
rationale = ""
|
||||
for line in lines:
|
||||
if 'entscheidung' in line.lower() or 'decision' in line.lower():
|
||||
decision = line.split(':', 1)[-1].strip() if ':' in line else line
|
||||
if 'begründung' in line.lower() or 'rationale' in line.lower():
|
||||
rationale = line.split(':', 1)[-1].strip() if ':' in line else line
|
||||
|
||||
description = f"Entscheidung: {decision}\nBegründung: {rationale}" if decision else block[:300]
|
||||
|
||||
pid = self.db.add_pattern(
|
||||
title=title[:100],
|
||||
category='architecture',
|
||||
pattern_type='architecture_decision',
|
||||
description=description,
|
||||
why_it_works=rationale[:500] if rationale else None,
|
||||
source_project_id=self.project_id,
|
||||
source_file=str(decisions_file),
|
||||
validated=0
|
||||
)
|
||||
|
||||
self.db.store_embedding(pid, description)
|
||||
self.db.log_learning(pid, self.project_id, 'decision_made',
|
||||
str(decisions_file), content)
|
||||
pattern_ids.append(pid)
|
||||
|
||||
return pattern_ids
|
||||
|
||||
def extract_from_architecture(self) -> List[int]:
|
||||
"""
|
||||
Extrahiert Patterns aus architecture.md.
|
||||
Erkennt: Tech-Stack, Architekturmuster, Docker-Setup.
|
||||
"""
|
||||
arch_file = self.a0_path / "architecture.md"
|
||||
if not arch_file.exists():
|
||||
return []
|
||||
|
||||
content = arch_file.read_text(encoding='utf-8')
|
||||
content_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
already = self.db.conn.execute(
|
||||
"SELECT id FROM learn_log WHERE source_file = ? AND source_content_hash = ?",
|
||||
(str(arch_file), content_hash)
|
||||
).fetchone()
|
||||
if already:
|
||||
return []
|
||||
|
||||
pattern_ids = []
|
||||
|
||||
# Nach bekannten Architekturmustern suchen
|
||||
patterns_to_detect = [
|
||||
('FastAPI', 'python', 'fastapi', 'best_practice'),
|
||||
('SQLAlchemy', 'database', 'sqlalchemy', 'best_practice'),
|
||||
('React', 'frontend', 'react', 'best_practice'),
|
||||
('Docker', 'docker', None, 'setup_guide'),
|
||||
('JWT', 'security', None, 'best_practice'),
|
||||
('REST API', 'architecture', None, 'architecture_decision'),
|
||||
]
|
||||
|
||||
for tech, category, subcat, ptype in patterns_to_detect:
|
||||
if tech.lower() in content.lower():
|
||||
# Kontext um das Keyword extrahieren
|
||||
idx = content.lower().find(tech.lower())
|
||||
start = max(0, idx - 100)
|
||||
end = min(len(content), idx + 300)
|
||||
context = content[start:end].strip()
|
||||
|
||||
pid = self.db.add_pattern(
|
||||
title=f"{tech} in {self.project_name}",
|
||||
category=category,
|
||||
subcategory=subcat,
|
||||
pattern_type=ptype,
|
||||
description=context,
|
||||
source_project_id=self.project_id,
|
||||
source_file=str(arch_file),
|
||||
validated=0
|
||||
)
|
||||
|
||||
self.db.store_embedding(pid, context)
|
||||
self.db.log_learning(pid, self.project_id, 'release_audit',
|
||||
str(arch_file), content)
|
||||
pattern_ids.append(pid)
|
||||
|
||||
return pattern_ids
|
||||
|
||||
def extract_from_docker(self) -> List[int]:
|
||||
"""
|
||||
Extrahiert Docker-Patterns aus Dockerfile und docker-compose.yml.
|
||||
"""
|
||||
pattern_ids = []
|
||||
|
||||
dockerfile = self.project_path / "Dockerfile"
|
||||
compose_file = self.project_path / "docker-compose.yml"
|
||||
|
||||
if dockerfile.exists():
|
||||
content = dockerfile.read_text()
|
||||
content_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
already = self.db.conn.execute(
|
||||
"SELECT id FROM learn_log WHERE source_file = ? AND source_content_hash = ?",
|
||||
(str(dockerfile), content_hash)
|
||||
).fetchone()
|
||||
|
||||
if not already:
|
||||
pid = self.db.add_pattern(
|
||||
title=f"Dockerfile aus {self.project_name}",
|
||||
category='docker',
|
||||
pattern_type='setup_guide',
|
||||
description=f"Docker-Konfiguration für {self.project_name}",
|
||||
code_example=content,
|
||||
source_project_id=self.project_id,
|
||||
source_file=str(dockerfile),
|
||||
validated=0
|
||||
)
|
||||
self.db.store_embedding(pid, content[:1000])
|
||||
self.db.log_learning(pid, self.project_id, 'deployment_success',
|
||||
str(dockerfile), content)
|
||||
pattern_ids.append(pid)
|
||||
|
||||
if compose_file.exists():
|
||||
content = compose_file.read_text()
|
||||
content_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
already = self.db.conn.execute(
|
||||
"SELECT id FROM learn_log WHERE source_file = ? AND source_content_hash = ?",
|
||||
(str(compose_file), content_hash)
|
||||
).fetchone()
|
||||
|
||||
if not already:
|
||||
pid = self.db.add_pattern(
|
||||
title=f"Docker Compose aus {self.project_name}",
|
||||
category='docker',
|
||||
pattern_type='deployment_config',
|
||||
description=f"Docker-Compose-Konfiguration für {self.project_name}",
|
||||
code_example=content,
|
||||
source_project_id=self.project_id,
|
||||
source_file=str(compose_file),
|
||||
validated=0
|
||||
)
|
||||
self.db.store_embedding(pid, content[:1000])
|
||||
self.db.log_learning(pid, self.project_id, 'deployment_success',
|
||||
str(compose_file), content)
|
||||
pattern_ids.append(pid)
|
||||
|
||||
return pattern_ids
|
||||
|
||||
def extract_from_deployment(self) -> List[int]:
|
||||
"""
|
||||
Extrahiert Deployment-Patterns aus runtime_report.md und env.md.
|
||||
"""
|
||||
pattern_ids = []
|
||||
|
||||
for filename in ['runtime_report.md', 'env.md']:
|
||||
filepath = self.a0_path / filename
|
||||
if not filepath.exists():
|
||||
continue
|
||||
|
||||
content = filepath.read_text()
|
||||
content_hash = hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
already = self.db.conn.execute(
|
||||
"SELECT id FROM learn_log WHERE source_file = ? AND source_content_hash = ?",
|
||||
(str(filepath), content_hash)
|
||||
).fetchone()
|
||||
|
||||
if not already:
|
||||
pid = self.db.add_pattern(
|
||||
title=f"{filename.replace('.md','')} aus {self.project_name}",
|
||||
category='deployment',
|
||||
pattern_type='setup_guide',
|
||||
description=content[:500],
|
||||
source_project_id=self.project_id,
|
||||
source_file=str(filepath),
|
||||
validated=0
|
||||
)
|
||||
self.db.store_embedding(pid, content[:1000])
|
||||
self.db.log_learning(pid, self.project_id, 'deployment_success',
|
||||
str(filepath), content)
|
||||
pattern_ids.append(pid)
|
||||
|
||||
return pattern_ids
|
||||
|
||||
def extract_all(self) -> Dict[str, List[int]]:
|
||||
"""
|
||||
Führt alle Extraktoren aus und gibt Summary zurück.
|
||||
"""
|
||||
results = {
|
||||
'errors': self.extract_from_known_errors(),
|
||||
'decisions': self.extract_from_decisions(),
|
||||
'architecture': self.extract_from_architecture(),
|
||||
'docker': self.extract_from_docker(),
|
||||
'deployment': self.extract_from_deployment(),
|
||||
}
|
||||
|
||||
# Projekt-Metriken updaten
|
||||
total = sum(len(v) for v in results.values())
|
||||
if total > 0:
|
||||
self.db.conn.execute("""
|
||||
UPDATE projects
|
||||
SET patterns_extracted = COALESCE(patterns_extracted, 0) + ?,
|
||||
last_extraction_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""", (total, self.project_id))
|
||||
self.db.conn.commit()
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _extract_code_block(text: str) -> Optional[str]:
|
||||
"""Extrahiert einen Code-Block (```...```) aus Text."""
|
||||
match = re.search(r'```[\s\S]*?```', text)
|
||||
if match:
|
||||
code = match.group(0)
|
||||
# Markdown-Fences entfernen
|
||||
code = re.sub(r'^```\w*\n?', '', code)
|
||||
code = re.sub(r'\n?```$', '', code)
|
||||
return code.strip()
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience-Funktion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_from_project(project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extrahiert Patterns aus einem Projekt und gibt eine Zusammenfassung zurück.
|
||||
|
||||
Args:
|
||||
project_path: Pfad zum Projekt-Root
|
||||
|
||||
Returns:
|
||||
Dict mit Summary der Extraktion
|
||||
"""
|
||||
extractor = PatternExtractor(Path(project_path))
|
||||
results = extractor.extract_all()
|
||||
|
||||
total = sum(len(v) for v in results.values())
|
||||
|
||||
return {
|
||||
'project': extractor.project_name,
|
||||
'project_id': extractor.project_id,
|
||||
'total_patterns_extracted': total,
|
||||
'by_category': results,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
A0 Software Orchestrator – Schema-Migrationen
|
||||
Verwaltet inkrementelle Updates der patterns.db.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db import PatternDB
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migrations-Definitionen
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key = Versionsnummer, Value = SQL-Statement
|
||||
# Migrationen werden in aufsteigender Reihenfolge ausgeführt.
|
||||
|
||||
MIGRATIONS = {
|
||||
1: """
|
||||
-- Version 1: Initiales Schema (siehe schema.sql)
|
||||
-- Diese Migration ist nur ein Marker – das vollständige Schema
|
||||
-- wird in schema.sql verwaltet und bei neuen DBs ausgeführt.
|
||||
SELECT 1; -- No-Op, Marker
|
||||
""",
|
||||
2: """
|
||||
-- Version 2: Normalisiertes Projekt-Registry-Schema
|
||||
-- Teilt projects_registry in drei Tabellen:
|
||||
-- projects = stabile Identität (name, git_url, description, tech_stack)
|
||||
-- project_state = laufender Zustand (phase, status, tasks, errors, …)
|
||||
-- project_snapshots = Historie (state_json, trigger, commit_hash)
|
||||
|
||||
-- 2a. Neue Tabellen anlegen
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
git_url TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
tech_stack TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_state (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
status TEXT DEFAULT 'active',
|
||||
phase TEXT DEFAULT 'intake',
|
||||
plan_mode TEXT,
|
||||
total_tasks INTEGER DEFAULT 0,
|
||||
completed_tasks INTEGER DEFAULT 0,
|
||||
open_errors INTEGER DEFAULT 0,
|
||||
last_active_at TEXT,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
snapshot_at TEXT DEFAULT (datetime('now')),
|
||||
state_json TEXT NOT NULL,
|
||||
trigger TEXT,
|
||||
commit_hash TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
-- 2b. Daten aus projects_registry migrieren
|
||||
INSERT INTO projects (name, git_url, description, tech_stack, created_at)
|
||||
SELECT project_name, git_url,
|
||||
COALESCE(description, ''),
|
||||
COALESCE(tech_stack, '{}'),
|
||||
COALESCE(created_at, datetime('now'))
|
||||
FROM projects_registry
|
||||
WHERE project_name IS NOT NULL;
|
||||
|
||||
INSERT INTO project_state (project_id, status, phase, plan_mode, total_tasks,
|
||||
completed_tasks, open_errors, last_active_at, completed_at)
|
||||
SELECT p.id, r.status, r.phase, r.plan_mode,
|
||||
COALESCE(r.total_tasks, 0), COALESCE(r.completed_tasks, 0),
|
||||
COALESCE(r.open_errors, 0), r.last_active_at, r.completed_at
|
||||
FROM projects_registry r
|
||||
JOIN projects p ON r.project_name = p.name;
|
||||
|
||||
-- 2c. Alte Tabelle umbenennen (Sicherheit – kein DROP)
|
||||
ALTER TABLE projects_registry RENAME TO projects_registry_legacy;
|
||||
""",
|
||||
3: """
|
||||
-- Version 3: Orchestrator-State-Tabellen (orch_*)
|
||||
-- Migration: .a0/*.json/.md und .a0proj/handover/*.md → patterns.db
|
||||
-- Ersetzt file-basierten State durch DB-Tabellen, project-scoped.
|
||||
|
||||
-- 3a. Generischer Key/Value Store (JSON-Values)
|
||||
-- ersetzt: project_state.json, orchestrator_mode.json,
|
||||
-- task_graph.json, tool_capabilities.json,
|
||||
-- tool_registry.json, manifests/*.json
|
||||
CREATE TABLE IF NOT EXISTS orch_kv (
|
||||
project_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, key),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_kv_project ON orch_kv(project_id);
|
||||
|
||||
-- 3b. Append-only Worklog
|
||||
-- ersetzt: .a0/worklog.md
|
||||
CREATE TABLE IF NOT EXISTS orch_worklog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
phase TEXT,
|
||||
work_block TEXT,
|
||||
agent TEXT,
|
||||
summary TEXT,
|
||||
details TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_worklog_project
|
||||
ON orch_worklog(project_id, created_at DESC);
|
||||
|
||||
-- 3c. Todos (offene + abgeschlossene)
|
||||
-- ersetzt: .a0/todo.md
|
||||
CREATE TABLE IF NOT EXISTS orch_todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'open', -- open, in_progress, done, blocked, cancelled
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_todos_project
|
||||
ON orch_todos(project_id, status);
|
||||
|
||||
-- 3d. Current Status (1-Zeilen-Projektstatus)
|
||||
-- ersetzt: .a0/current_status.md
|
||||
CREATE TABLE IF NOT EXISTS orch_status (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3e. Next Steps (offene Schritte)
|
||||
-- ersetzt: .a0/next_steps.md
|
||||
CREATE TABLE IF NOT EXISTS orch_next_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending, in_progress, done, blocked
|
||||
order_idx INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_next_steps_project
|
||||
ON orch_next_steps(project_id, order_idx);
|
||||
|
||||
-- 3f. Known Errors (offene + gelöste)
|
||||
-- ersetzt: .a0/known_errors.md
|
||||
CREATE TABLE IF NOT EXISTS orch_errors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
error_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
severity TEXT DEFAULT 'medium', -- low, medium, high, critical
|
||||
status TEXT DEFAULT 'open', -- open, investigating, resolved, wontfix
|
||||
context TEXT,
|
||||
resolution TEXT,
|
||||
discovered TEXT DEFAULT (datetime('now')),
|
||||
resolved_at TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_errors_project
|
||||
ON orch_errors(project_id, status);
|
||||
|
||||
-- 3g. Block-Compact Marker (ersetzt .a0/resume.md + .a0/conversation_summary.md)
|
||||
CREATE TABLE IF NOT EXISTS orch_block_compact (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
last_block_id TEXT,
|
||||
last_compact_at TEXT,
|
||||
next_block TEXT,
|
||||
block_summary TEXT,
|
||||
decisions_json TEXT, -- JSON array
|
||||
key_findings_json TEXT, -- JSON array
|
||||
open_questions_json TEXT, -- JSON array
|
||||
resume_md TEXT,
|
||||
conversation_summary_md TEXT,
|
||||
context_ratio REAL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3h. Session Snapshots (ersetzt .a0/session_snapshots/*.json)
|
||||
CREATE TABLE IF NOT EXISTS orch_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
snapshot_at TEXT DEFAULT (datetime('now')),
|
||||
trigger TEXT, -- e.g. 'block_compact', 'phase_transition', 'manual'
|
||||
commit_hash TEXT,
|
||||
state_json TEXT NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_snapshots_project
|
||||
ON orch_snapshots(project_id, snapshot_at DESC);
|
||||
|
||||
-- 3i. Scorecard Entries (ersetzt .a0/project_scorecard.md)
|
||||
CREATE TABLE IF NOT EXISTS orch_scorecard (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
max_score INTEGER DEFAULT 100,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_scorecard_project
|
||||
ON orch_scorecard(project_id, created_at DESC);
|
||||
|
||||
-- 3j. Briefings (ersetzt .a0proj/handover/*.md)
|
||||
-- Persistente Handover-Briefings für Persona-Modus
|
||||
CREATE TABLE IF NOT EXISTS orch_briefings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
specialist TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
section_md TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
decisions_json TEXT,
|
||||
artifacts_json TEXT,
|
||||
recent_turns_json TEXT,
|
||||
return_condition TEXT,
|
||||
handover_reason TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_briefings_project
|
||||
ON orch_briefings(project_id, specialist, created_at DESC);
|
||||
""",
|
||||
4: """
|
||||
-- Version 4: Quality gate runtime tables.
|
||||
CREATE TABLE IF NOT EXISTS orch_quality_config (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
tech_stack TEXT NOT NULL DEFAULT 'unknown',
|
||||
coverage_min_pct INTEGER DEFAULT 70,
|
||||
strict_types INTEGER DEFAULT 1,
|
||||
require_security_scan INTEGER DEFAULT 1,
|
||||
require_openapi_check INTEGER DEFAULT 0,
|
||||
custom_checks_json TEXT DEFAULT '[]',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_quality_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
gate_level TEXT NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
checks_json TEXT NOT NULL,
|
||||
blockers_json TEXT NOT NULL,
|
||||
next_action TEXT,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
triggered_by TEXT DEFAULT 'manual',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_quality_runs_project
|
||||
ON orch_quality_runs(project_id, created_at DESC);
|
||||
""",
|
||||
5: """
|
||||
-- Version 5: Globale Plugin-Settings (kein Project-Scope)
|
||||
-- Ersetzt den project_id=0-Workaround in orch_kv (plan_mode_guard,
|
||||
-- siehe Bugfix-Auto-Registration §3.7). plugin_settings hat KEINE
|
||||
-- FK auf projects, ist also für plugin-weite Konfiguration.
|
||||
CREATE TABLE IF NOT EXISTS plugin_settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(db: "PatternDB", name: str) -> bool:
|
||||
row = db.conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = ?",
|
||||
(name,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _column_exists(db: "PatternDB", table: str, column: str) -> bool:
|
||||
try:
|
||||
return any(r[1] == column for r in db.conn.execute(f"PRAGMA table_info({table})"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _add_column_if_missing(db: "PatternDB", table: str, column: str, ddl: str) -> None:
|
||||
if _table_exists(db, table) and not _column_exists(db, table, column):
|
||||
db.conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
|
||||
|
||||
|
||||
def ensure_runtime_schema(db: "PatternDB") -> None:
|
||||
"""Idempotently enforce all runtime tables/columns regardless of migration history."""
|
||||
db.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
git_url TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
tech_stack TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_state (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
status TEXT DEFAULT 'active',
|
||||
phase TEXT DEFAULT 'intake',
|
||||
plan_mode TEXT,
|
||||
total_tasks INTEGER DEFAULT 0,
|
||||
completed_tasks INTEGER DEFAULT 0,
|
||||
open_errors INTEGER DEFAULT 0,
|
||||
last_active_at TEXT,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
snapshot_at TEXT DEFAULT (datetime('now')),
|
||||
state_json TEXT NOT NULL,
|
||||
trigger TEXT,
|
||||
commit_hash TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_kv (
|
||||
project_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, key),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_worklog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
phase TEXT,
|
||||
work_block TEXT,
|
||||
agent TEXT,
|
||||
summary TEXT,
|
||||
details TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'open',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_status (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_next_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
order_idx INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_errors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
error_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
severity TEXT DEFAULT 'medium',
|
||||
status TEXT DEFAULT 'open',
|
||||
context TEXT,
|
||||
resolution TEXT,
|
||||
discovered TEXT DEFAULT (datetime('now')),
|
||||
resolved_at TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_block_compact (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
last_block_id TEXT,
|
||||
last_compact_at TEXT,
|
||||
next_block TEXT,
|
||||
block_summary TEXT,
|
||||
decisions_json TEXT,
|
||||
key_findings_json TEXT,
|
||||
open_questions_json TEXT,
|
||||
resume_md TEXT,
|
||||
conversation_summary_md TEXT,
|
||||
context_ratio REAL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
snapshot_at TEXT DEFAULT (datetime('now')),
|
||||
trigger TEXT,
|
||||
commit_hash TEXT,
|
||||
state_json TEXT NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_scorecard (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
max_score INTEGER DEFAULT 100,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_briefings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
specialist TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
section_md TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
decisions_json TEXT,
|
||||
artifacts_json TEXT,
|
||||
recent_turns_json TEXT,
|
||||
return_condition TEXT,
|
||||
handover_reason TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_quality_config (
|
||||
project_id INTEGER PRIMARY KEY,
|
||||
tech_stack TEXT NOT NULL DEFAULT 'unknown',
|
||||
coverage_min_pct INTEGER DEFAULT 70,
|
||||
strict_types INTEGER DEFAULT 1,
|
||||
require_security_scan INTEGER DEFAULT 1,
|
||||
require_openapi_check INTEGER DEFAULT 0,
|
||||
custom_checks_json TEXT DEFAULT '[]',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orch_quality_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
gate_level TEXT NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
checks_json TEXT NOT NULL,
|
||||
blockers_json TEXT NOT NULL,
|
||||
next_action TEXT,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
triggered_by TEXT DEFAULT 'manual',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
for ddl in [
|
||||
"project_path TEXT",
|
||||
"patterns_extracted INTEGER DEFAULT 0",
|
||||
"last_extraction_at TEXT",
|
||||
]:
|
||||
col = ddl.split()[0]
|
||||
_add_column_if_missing(db, "projects", col, ddl)
|
||||
db.conn.executescript("""
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_state_status ON project_state(status, phase);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_worklog_project ON orch_worklog(project_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_todos_project ON orch_todos(project_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_next_steps_project ON orch_next_steps(project_id, order_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_errors_project ON orch_errors(project_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_snapshots_project ON orch_snapshots(project_id, snapshot_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_scorecard_project ON orch_scorecard(project_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_briefings_project ON orch_briefings(project_id, specialist, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_orch_quality_runs_project ON orch_quality_runs(project_id, created_at DESC);
|
||||
""")
|
||||
db.conn.commit()
|
||||
|
||||
|
||||
def run_migrations(db: "PatternDB"):
|
||||
"""
|
||||
Führt ausstehende Schema-Migrationen aus.
|
||||
|
||||
Args:
|
||||
db: PatternDB-Instanz
|
||||
"""
|
||||
# Aktuelle Schema-Version ermitteln
|
||||
cursor = db.conn.execute(
|
||||
"SELECT MAX(version) FROM schema_version"
|
||||
)
|
||||
current = cursor.fetchone()[0] or 0
|
||||
|
||||
# Fehlende Migrationen in Reihenfolge ausführen
|
||||
for version in sorted(MIGRATIONS.keys()):
|
||||
if version <= current:
|
||||
continue
|
||||
|
||||
try:
|
||||
db.conn.executescript(MIGRATIONS[version])
|
||||
db.conn.execute(
|
||||
"INSERT INTO schema_version (version, description) VALUES (?, ?)",
|
||||
(version, f"Migration {version}")
|
||||
)
|
||||
db.conn.commit()
|
||||
print(f"[PatternDB] ✅ Migration {version} ausgeführt")
|
||||
except Exception as e:
|
||||
print(f"[PatternDB] ❌ Migration {version} fehlgeschlagen: {e}")
|
||||
raise
|
||||
|
||||
ensure_runtime_schema(db)
|
||||
@@ -0,0 +1,305 @@
|
||||
-- ============================================================
|
||||
-- A0 Software Orchestrator – Patterns-Bibliothek
|
||||
-- SQLite Schema v1.0.0
|
||||
-- ============================================================
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 1. PROJEKT-REGISTRY (zentrale Projekt-Liste)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS projects_registry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Identifikation
|
||||
project_name TEXT NOT NULL UNIQUE,
|
||||
project_path TEXT NOT NULL,
|
||||
git_url TEXT,
|
||||
|
||||
-- Status
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
-- active, paused, completed, archived, failed
|
||||
phase TEXT,
|
||||
-- intake, requirements, architecture, implementation,
|
||||
-- testing, deployment, maintenance
|
||||
plan_mode TEXT,
|
||||
-- planning_only, implementation_allowed,
|
||||
-- runtime_verification_allowed, deployment_preparation_allowed,
|
||||
-- release_handoff_allowed, maintenance_allowed
|
||||
|
||||
-- Tech-Stack (JSON)
|
||||
tech_stack TEXT,
|
||||
-- {"backend":"FastAPI","frontend":"React","db":"SQLite",
|
||||
-- "orm":"SQLAlchemy","docker":true}
|
||||
|
||||
-- Metriken
|
||||
total_tasks INTEGER DEFAULT 0,
|
||||
completed_tasks INTEGER DEFAULT 0,
|
||||
open_errors INTEGER DEFAULT 0,
|
||||
|
||||
-- Zeitstempel
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
last_active_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
|
||||
-- Verknüpfung zur Patterns-Bibliothek
|
||||
patterns_extracted INTEGER DEFAULT 0,
|
||||
last_extraction_at TEXT,
|
||||
|
||||
-- Notizen
|
||||
description TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects_registry(status, phase);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_active ON projects_registry(last_active_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_tech ON projects_registry(tech_stack);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 2. PATTERNS (das eigentliche Wissen)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS patterns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Basis
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
-- docker, python, frontend, deployment, error_fix,
|
||||
-- architecture, framework, workflow, database, security
|
||||
subcategory TEXT,
|
||||
-- z.B. 'fastapi', 'react', 'sqlalchemy', 'postgresql'
|
||||
pattern_type TEXT NOT NULL,
|
||||
-- code_snippet, architecture_decision, error_solution,
|
||||
-- best_practice, setup_guide, deployment_config,
|
||||
-- workflow_pattern, anti_pattern
|
||||
|
||||
-- Inhalt
|
||||
description TEXT NOT NULL,
|
||||
code_example TEXT,
|
||||
when_to_use TEXT,
|
||||
why_it_works TEXT,
|
||||
pitfalls TEXT,
|
||||
|
||||
-- Meta
|
||||
source_project_id INTEGER REFERENCES projects_registry(id),
|
||||
source_task_id TEXT,
|
||||
source_error TEXT,
|
||||
source_file TEXT,
|
||||
|
||||
-- Qualität & Validierung
|
||||
validated INTEGER DEFAULT 0,
|
||||
-- 0 = automatisch extrahiert, ungeprüft
|
||||
-- 1 = durch quality_reviewer bestätigt
|
||||
-- 2 = manuell erstellt/geprüft
|
||||
-- -1 = veraltet/deprecated
|
||||
validation_date TEXT,
|
||||
validated_by TEXT,
|
||||
|
||||
-- Nutzungs-Statistik
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
success_rate REAL DEFAULT 0.0,
|
||||
|
||||
-- Aging
|
||||
staleness_score REAL DEFAULT 0.0,
|
||||
-- 0.0 = frisch, 1.0 = stark veraltet
|
||||
framework_version TEXT,
|
||||
-- z.B. "FastAPI 0.115", "React 19", "Python 3.12"
|
||||
|
||||
-- Zeitstempel
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patterns_category ON patterns(category, pattern_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_patterns_project ON patterns(source_project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patterns_valid ON patterns(validated);
|
||||
CREATE INDEX IF NOT EXISTS idx_patterns_stale ON patterns(staleness_score);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 3. TAGS (flexible Verschlagwortung)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pattern_tags (
|
||||
pattern_id INTEGER REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (pattern_id, tag_id)
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 4. PATTERN-BEZIEHUNGEN
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pattern_relations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern_a_id INTEGER REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
pattern_b_id INTEGER REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL,
|
||||
-- 'often_used_with', 'alternative_to', 'depends_on',
|
||||
-- 'replaces', 'is_replaced_by', 'conflicts_with'
|
||||
strength REAL DEFAULT 1.0,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(pattern_a_id, pattern_b_id, relation_type)
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 5. PATTERN-KONFLIKTE (widersprüchliche Patterns)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pattern_conflicts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern_a_id INTEGER REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
pattern_b_id INTEGER REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
conflict_type TEXT NOT NULL,
|
||||
-- 'direct_contradiction', 'context_dependent', 'version_specific'
|
||||
description TEXT,
|
||||
resolution_context TEXT,
|
||||
-- JSON: {"if": "high_traffic", "use": "A", "if": "simple", "use": "B"}
|
||||
resolved_by TEXT DEFAULT 'system',
|
||||
-- 'system', 'quality_reviewer', 'manual'
|
||||
resolved_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 6. PATTERN-FEEDBACK (negative + positive Lernerfahrungen)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pattern_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern_id INTEGER REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
project_id INTEGER REFERENCES projects_registry(id),
|
||||
|
||||
outcome TEXT NOT NULL,
|
||||
-- 'success', 'failure', 'partial_success', 'not_applicable'
|
||||
reason TEXT,
|
||||
context_diff TEXT,
|
||||
-- Was war anders als beim ursprünglichen Pattern?
|
||||
|
||||
-- Bei Fehlschlag: Was wurde stattdessen verwendet?
|
||||
alternative_used TEXT,
|
||||
alternative_pattern_id INTEGER REFERENCES patterns(id),
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_pattern ON pattern_feedback(pattern_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_project ON pattern_feedback(project_id);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 7. LERN-LOG (Historie für Debugging)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS learn_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern_id INTEGER REFERENCES patterns(id),
|
||||
project_id INTEGER REFERENCES projects_registry(id),
|
||||
|
||||
trigger TEXT NOT NULL,
|
||||
-- 'task_completed', 'error_fixed', 'deployment_success',
|
||||
-- 'decision_made', 'release_audit', 'manual_entry'
|
||||
source_file TEXT,
|
||||
source_content_hash TEXT,
|
||||
|
||||
extracted_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 8. SCHEMA-VERSION (Migration-Tracking)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Initiale Version eintragen
|
||||
INSERT OR IGNORE INTO schema_version (version, description)
|
||||
VALUES (1, 'Initiales Schema: patterns, projects_registry, tags, relations, conflicts, feedback, learn_log');
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 9. FTS5 VOLLTEXT-INDEX
|
||||
-- -----------------------------------------------------------
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS patterns_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
code_example,
|
||||
when_to_use,
|
||||
why_it_works,
|
||||
pitfalls,
|
||||
content='patterns',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- Trigger: Automatische FTS-Synchronisation
|
||||
CREATE TRIGGER IF NOT EXISTS patterns_ai AFTER INSERT ON patterns BEGIN
|
||||
INSERT INTO patterns_fts(rowid, title, description, code_example, when_to_use, why_it_works, pitfalls)
|
||||
VALUES (new.id, new.title, new.description, new.code_example, new.when_to_use, new.why_it_works, new.pitfalls);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS patterns_ad AFTER DELETE ON patterns BEGIN
|
||||
INSERT INTO patterns_fts(patterns_fts, rowid, title, description, code_example, when_to_use, why_it_works, pitfalls)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.code_example, old.when_to_use, old.why_it_works, old.pitfalls);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS patterns_au AFTER UPDATE ON patterns BEGIN
|
||||
INSERT INTO patterns_fts(patterns_fts, rowid, title, description, code_example, when_to_use, why_it_works, pitfalls)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.code_example, old.when_to_use, old.why_it_works, old.pitfalls);
|
||||
INSERT INTO patterns_fts(rowid, title, description, code_example, when_to_use, why_it_works, pitfalls)
|
||||
VALUES (new.id, new.title, new.description, new.code_example, new.when_to_use, new.why_it_works, new.pitfalls);
|
||||
END;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 10. VEKTOR-TABELLE (sqlite-vec, optional)
|
||||
-- -----------------------------------------------------------
|
||||
-- Wird von db.py dynamisch erstellt, wenn sqlite-vec verfügbar ist.
|
||||
-- CREATE VIRTUAL TABLE IF NOT EXISTS pattern_embeddings USING vec0(
|
||||
-- embedding float[384] -- all-MiniLM-L6-v2 (Standard-Modell)
|
||||
-- );
|
||||
|
||||
-- ============================================================
|
||||
-- NÜTZLICHE VIEWS
|
||||
-- ============================================================
|
||||
|
||||
-- Aktive Projekt-Übersicht
|
||||
CREATE VIEW IF NOT EXISTS v_active_projects AS
|
||||
SELECT
|
||||
project_name,
|
||||
status,
|
||||
phase,
|
||||
completed_tasks || '/' || total_tasks AS progress,
|
||||
open_errors,
|
||||
patterns_extracted,
|
||||
last_active_at
|
||||
FROM projects_registry
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_active_at DESC;
|
||||
|
||||
-- Validierten Patterns mit Nutzungsstatistik
|
||||
CREATE VIEW IF NOT EXISTS v_ready_patterns AS
|
||||
SELECT
|
||||
p.id, p.title, p.category, p.pattern_type,
|
||||
p.usage_count, p.success_count, p.failure_count,
|
||||
CASE WHEN p.success_count + p.failure_count > 0
|
||||
THEN ROUND(p.success_count * 100.0 / (p.success_count + p.failure_count), 1)
|
||||
ELSE NULL END AS success_pct,
|
||||
p.staleness_score,
|
||||
p.created_at, p.last_used_at
|
||||
FROM patterns p
|
||||
WHERE p.validated >= 0 -- Nur aktive, nicht deprecated
|
||||
ORDER BY p.usage_count DESC;
|
||||
|
||||
-- Konflikt-Übersicht
|
||||
CREATE VIEW IF NOT EXISTS v_conflicts AS
|
||||
SELECT
|
||||
pc.id,
|
||||
a.title AS pattern_a,
|
||||
b.title AS pattern_b,
|
||||
pc.conflict_type,
|
||||
pc.resolution_context,
|
||||
pc.resolved_by
|
||||
FROM pattern_conflicts pc
|
||||
JOIN patterns a ON pc.pattern_a_id = a.id
|
||||
JOIN patterns b ON pc.pattern_b_id = b.id;
|
||||
@@ -0,0 +1,25 @@
|
||||
"""JSON schema definitions for project manifests."""
|
||||
|
||||
PROJECT_MANIFEST_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "object"},
|
||||
"phase": {"type": "object"},
|
||||
"progress": {"type": "object"},
|
||||
"quality": {"type": "object"},
|
||||
"next_action": {"type": "object"},
|
||||
},
|
||||
"required": ["project", "phase"],
|
||||
}
|
||||
|
||||
DEPLOYMENT_MANIFEST_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {"type": "string"},
|
||||
"docker_compose": {"type": "boolean"},
|
||||
"env_file": {"type": "boolean"},
|
||||
"healthcheck": {"type": "boolean"},
|
||||
"runbook": {"type": "boolean"},
|
||||
"rollback": {"type": "boolean"},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Markdown templates for project artifacts."""
|
||||
|
||||
# Core template definitions; loaded from prompts/project_templates if possible,
|
||||
# otherwise use minimal inline defaults.
|
||||
|
||||
def get_worklog_entry(timestamp: str, phase: str, work_block: int, agent: str, summary: str) -> str:
|
||||
"""Return a worklog table row."""
|
||||
return f"| {timestamp} | {phase} | {work_block} | {agent} | {summary} | completed |"
|
||||
|
||||
|
||||
def get_known_error_entry(error_id: str, title: str, status: str, discovered: str, severity: str, context: str, resolution: str = "") -> str:
|
||||
"""Return a known error section."""
|
||||
return f"""### Error {error_id}: {title}
|
||||
- Status: {status}
|
||||
- Discovered: {discovered}
|
||||
- Severity: {severity}
|
||||
- Context: {context}
|
||||
- Resolution: {resolution or "pending"}
|
||||
"""
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Model routing configuration management."""
|
||||
import yaml
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
def load_config(plugin_dir: str) -> Dict[str, Any]:
|
||||
"""Load model routing config from default_config.yaml."""
|
||||
config_path = os.path.join(plugin_dir, "default_config.yaml")
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
return config.get("model_routing", {})
|
||||
except (FileNotFoundError, yaml.YAMLError) as e:
|
||||
return {}
|
||||
|
||||
|
||||
def get_role_preset(role: str, config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Get the model preset for a given role."""
|
||||
roles = config.get("roles", {})
|
||||
role_config = roles.get(role, {})
|
||||
return role_config.get("preset")
|
||||
|
||||
|
||||
def list_roles(config: Dict[str, Any]) -> list:
|
||||
"""Return list of defined role names."""
|
||||
return list(config.get("roles", {}).keys())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Quality gate validation and scoring."""
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
def check_gate(gate_name: str, project_root: str) -> Dict[str, Any]:
|
||||
"""Check a single quality gate. Stub for v0.1."""
|
||||
# In v0.1, this is a simple placeholder that returns pending.
|
||||
return {"gate": gate_name, "status": "pending", "reason": "not yet checked"}
|
||||
|
||||
|
||||
def calculate_score(check_results: List[Dict[str, Any]]) -> int:
|
||||
"""Calculate a simple score from gate results."""
|
||||
if not check_results:
|
||||
return 0
|
||||
passed = sum(1 for r in check_results if r.get("status") == "passed")
|
||||
return int((passed / len(check_results)) * 100)
|
||||
|
||||
|
||||
def is_release_ready(score: int, min_score: int = 80) -> bool:
|
||||
"""Check if the project score meets the minimum for release."""
|
||||
return score >= min_score
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Session resume state reconstruction – DB-backed thin wrapper.
|
||||
|
||||
Beibehalten aus Kompatibilitätsgründen. Delegiert an db_state_store
|
||||
und compact_protocol. Neuer Code sollte direkt db_state_store / block_resume
|
||||
verwenden.
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.compact_protocol import get_resume_payload
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import get_project_id, create_snapshot as db_create_snapshot
|
||||
|
||||
|
||||
def read_resume_info(project_name: str = "", project_root: str = "") -> Dict[str, Any]:
|
||||
"""Liest den Resume-State aus der DB (rückwärtskompatibler Wrapper).
|
||||
|
||||
Args:
|
||||
project_name: Projektname (bevorzugt).
|
||||
project_root: Legacy – wird zu basename() aufgelöst.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"project_state": { project_id, block_compact: {...} },
|
||||
"resume_notes": <resume_md string> | None,
|
||||
"next_steps": <list of pending step dicts>,
|
||||
}
|
||||
"""
|
||||
# Bug-Fix: basename-Fallback entfernt – würde ungültige/Phantom-Namen
|
||||
# erzeugen. project_name ist Pflicht.
|
||||
if not project_name or not project_name.strip():
|
||||
return {"project_state": {}, "resume_notes": None, "next_steps": ""}
|
||||
name = project_name.strip()
|
||||
payload = get_resume_payload(name)
|
||||
|
||||
state: Dict[str, Any] = {
|
||||
"project_state": payload.get("project_state", {}),
|
||||
}
|
||||
if "resume" in payload:
|
||||
state["resume_notes"] = payload["resume"]
|
||||
if "conversation_summary" in payload:
|
||||
state["conversation_summary"] = payload["conversation_summary"]
|
||||
# next_steps als Liste von Dicts → String (rückwärtskompatibel)
|
||||
if payload.get("next_steps"):
|
||||
state["next_steps"] = "\n".join(
|
||||
f"- [id={s.get('id')}] {s.get('content')}"
|
||||
for s in payload["next_steps"]
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def create_snapshot(project_name: str = "", project_root: str = "") -> Optional[str]:
|
||||
"""Erstellt einen Snapshot in der DB und gibt die Snapshot-ID zurück
|
||||
(String zur Rückwärtskompatibilität – war früher ein Dateipfad).
|
||||
|
||||
Args:
|
||||
project_name: Projektname (bevorzugt).
|
||||
project_root: Legacy – wird zu basename() aufgelöst.
|
||||
|
||||
Returns:
|
||||
Snapshot-ID als String, oder None wenn Projekt nicht existiert.
|
||||
"""
|
||||
# Bug-Fix: basename-Fallback entfernt – würde ungültige/Phantom-Namen
|
||||
# erzeugen. project_name ist Pflicht.
|
||||
if not project_name or not project_name.strip():
|
||||
return None
|
||||
name = project_name.strip()
|
||||
try:
|
||||
pid = get_project_id(name)
|
||||
if pid is None:
|
||||
return None
|
||||
except (ValueError, LookupError):
|
||||
return None
|
||||
snap_id = db_create_snapshot(pid, trigger="resume_state.create_snapshot")
|
||||
return f"db://snapshots/{snap_id}"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Safety rules: Plan Mode, permissions, dangerous actions."""
|
||||
import re
|
||||
|
||||
# Patterns for potentially dangerous terminal commands
|
||||
DANGEROUS_PATTERNS = [
|
||||
r"rm\s+-rf\s+/.*",
|
||||
r"sudo\s+rm",
|
||||
r"mkfs\.",
|
||||
r"dd\s+if=",
|
||||
r">\s*/dev/sd",
|
||||
r"chmod\s+777",
|
||||
r"git\s+push\s+.*\s+main",
|
||||
r"git\s+push\s+.*\s+master",
|
||||
r"docker\s+rm\s+-f",
|
||||
r"docker\s+volume\s+rm",
|
||||
]
|
||||
|
||||
# Pattern to detect Base64-like values that may be secrets
|
||||
# Matches: api_key=XXXX, token:XXXX, secret="XXXX", etc.
|
||||
_SECRET_VALUE = re.compile(
|
||||
r'(?:api|access|secret|token|key|password|auth)'
|
||||
r'[-_]?'
|
||||
r'(?:key|token|secret)?'
|
||||
r'\s*[=:]\s*'
|
||||
r'[\'"]?'
|
||||
r'[A-Za-z0-9+/=_-]{20,}'
|
||||
r'[\'"]?'
|
||||
)
|
||||
|
||||
def is_dangerous_command(command: str) -> bool:
|
||||
"""Check if a terminal command matches known dangerous patterns."""
|
||||
for pattern in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, command, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
def contains_secret(text: str) -> bool:
|
||||
"""Check if text likely contains a hardcoded secret."""
|
||||
return bool(_SECRET_VALUE.search(text))
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> str:
|
||||
"""Return text with obvious credential-looking assignments redacted.
|
||||
|
||||
This is defensive output hygiene, not a credential scanner. It must only be
|
||||
applied to data already produced by approved checks.
|
||||
"""
|
||||
return _SECRET_VALUE.sub(lambda m: m.group(0).split('=')[0] + '=<REDACTED>' if '=' in m.group(0) else '<REDACTED_SECRET_LIKE_VALUE>', text)
|
||||
|
||||
|
||||
# Credential-discovery commands are never legitimate for this plugin.
|
||||
# This is not a scanner for collecting credentials; it is a pre-execution guard
|
||||
# for commands/plans that attempt credential discovery.
|
||||
CREDENTIAL_DISCOVERY_PATTERNS = [
|
||||
r"\b(grep|rg|ripgrep|find|cat|sed|awk|python|python3|node|perl)\b.*\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|private[_-]?key|credential|auth|cookie|session)\b",
|
||||
r"\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key|private[_-]?key|credential|auth|cookie|session)\b.*\b(grep|rg|ripgrep|find|cat|sed|awk|python|python3|node|perl)\b",
|
||||
r"\b(env|printenv|set)\b.*\b(password|token|secret|key|credential|auth)\b",
|
||||
r"\b\.env(\.|\b|$)",
|
||||
r"\b(config|settings|application|appsettings).*\b(password|token|secret|credential|auth)\b",
|
||||
r"\b(browser|chrome|firefox|localstorage|sessionstorage|cookie|cookies)\b.*\b(password|token|secret|credential|auth|session)\b",
|
||||
r"\bfind\b.*\b(login|credential|credentials|password|token|secret)\b",
|
||||
r"\b(read|open|dump|extract|list|locate|recover|discover|search)\b.*\b(login|credential|credentials|password|token|secret|cookie|session)\b",
|
||||
]
|
||||
|
||||
|
||||
def is_credential_discovery_command(command: str) -> bool:
|
||||
"""Return True if a command/plan appears to look for credential material."""
|
||||
c = str(command or "").lower()
|
||||
return any(re.search(pattern, c, re.IGNORECASE) for pattern in CREDENTIAL_DISCOVERY_PATTERNS)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Project state store for A0 Software Orchestrator."""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def read_json(path: str, default: Any = None) -> Any:
|
||||
"""Read a JSON file from the project root, return default if not found."""
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
def write_json(path: str, data: Any) -> None:
|
||||
"""Write data to a JSON file, creating directories if needed."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
|
||||
|
||||
def append_markdown(path: str, line: str) -> None:
|
||||
"""Append a line to a markdown file, creating directories if needed."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
"""Return an ISO 8601 timestamp string."""
|
||||
return datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
|
||||
def get_project_files(project_root: str) -> Dict[str, str]:
|
||||
"""Return a dict of known state file paths relative to project_root."""
|
||||
return {
|
||||
"project_state": os.path.join(project_root, ".a0", "project_state.json"),
|
||||
"current_status": os.path.join(project_root, ".a0", "current_status.md"),
|
||||
"worklog": os.path.join(project_root, ".a0", "worklog.md"),
|
||||
"todo": os.path.join(project_root, ".a0", "todo.md"),
|
||||
"known_errors": os.path.join(project_root, ".a0", "known_errors.md"),
|
||||
"next_steps": os.path.join(project_root, ".a0", "next_steps.md"),
|
||||
"task_graph": os.path.join(project_root, ".a0", "task_graph.json"),
|
||||
"orchestrator_mode": os.path.join(project_root, ".a0", "orchestrator_mode.json"),
|
||||
"tool_capabilities": os.path.join(project_root, ".a0", "tool_capabilities.json"),
|
||||
"resume": os.path.join(project_root, ".a0", "resume.md"),
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""conftest.py für helpers/tests/ – mockt helpers.tool, um plan_mode_guard-Tests
|
||||
zu ermöglichen, ohne das Agent-Framework (helpers.tool → helpers.history → langchain.prompts)
|
||||
laden zu müssen.
|
||||
|
||||
Wir mocken nur das minimale Tool/Response-Interface, das plan_mode_guard braucht.
|
||||
"""
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# Minimal-Stubs für helpers.tool: Tool als leere Klasse, Response als SimpleNamespace
|
||||
class _FakeResponse:
|
||||
def __init__(self, message="", break_loop=False):
|
||||
self.message = message
|
||||
self.break_loop = break_loop
|
||||
|
||||
|
||||
class _FakeTool:
|
||||
pass
|
||||
|
||||
|
||||
_fake_tool_module = SimpleNamespace(Tool=_FakeTool, Response=_FakeResponse)
|
||||
sys.modules["helpers.tool"] = _fake_tool_module
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Tests für helpers/db_state_store.py – Auto-Registration Bugfix (Plan v3 §4).
|
||||
|
||||
Verwendet tmp-DB (NICHT die echte patterns.db). PatternDB ist Singleton,
|
||||
daher wird pro Test resettet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# --- Fixture: tmp-DB pro Test -------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(tmp_path, monkeypatch):
|
||||
"""Erstellt eine frische in-memory-ähnliche Test-DB und patched die Singleton.
|
||||
|
||||
PatternDB ist Singleton; wir resetten _instance pro Test und instanziieren
|
||||
mit einem tmp_path, damit die echte patterns.db nicht berührt wird.
|
||||
"""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
from usr.plugins.a0_software_orchestrator.helpers import db_state_store
|
||||
|
||||
db_path = tmp_path / "test_patterns.db"
|
||||
PatternDB._instance = None # Singleton reset
|
||||
db = PatternDB(db_path) # Erstellt + initialisiert + migrations
|
||||
yield db, db_path
|
||||
# Cleanup
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
PatternDB._instance = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Validator (4 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestValidateProjectName:
|
||||
def test_validate_project_name_ok(self):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
_validate_project_name,
|
||||
)
|
||||
for name in ("crm-system", "web-cad", "rentman-clone", "a1b"):
|
||||
assert _validate_project_name(name) == name, f"'{name}' should pass"
|
||||
|
||||
def test_validate_project_name_pattern_fail(self):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
_validate_project_name,
|
||||
)
|
||||
for name in ("A0", "Crm System", "a", "", "my.app", "cool_app", "müller"):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_project_name(name)
|
||||
|
||||
def test_validate_project_name_blacklist(self):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
_validate_project_name,
|
||||
)
|
||||
for name in ("a0", "a0-development", "workdir", "dev-projects"):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_project_name(name)
|
||||
|
||||
def test_validate_project_name_whitespace_strip(self):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
_validate_project_name,
|
||||
)
|
||||
assert _validate_project_name(" crm ") == "crm"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# register_project (4 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestRegisterProject:
|
||||
def test_register_project_new(self, tmp_db):
|
||||
db, _ = tmp_db
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project,
|
||||
)
|
||||
pid = register_project("crm-system", git_url="https://x.com/y.git",
|
||||
description="Test")
|
||||
assert isinstance(pid, int) and pid > 0
|
||||
row = db.conn.execute(
|
||||
"SELECT name, git_url, description FROM projects WHERE id = ?", (pid,)
|
||||
).fetchone()
|
||||
assert row["name"] == "crm-system"
|
||||
assert row["git_url"] == "https://x.com/y.git"
|
||||
assert row["description"] == "Test"
|
||||
|
||||
def test_register_project_idempotent(self, tmp_db):
|
||||
db, _ = tmp_db
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project,
|
||||
)
|
||||
pid1 = register_project("crm-system")
|
||||
pid2 = register_project("crm-system")
|
||||
assert pid1 == pid2
|
||||
# Nur ein Eintrag
|
||||
n = db.conn.execute(
|
||||
"SELECT COUNT(*) as c FROM projects WHERE name = 'crm-system'"
|
||||
).fetchone()["c"]
|
||||
assert n == 1
|
||||
|
||||
def test_register_project_value_error_wraps_validator(self, tmp_db):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
register_project("a0") # Blacklist
|
||||
with pytest.raises(ValueError):
|
||||
register_project("Cool_App") # Pattern-Fail
|
||||
|
||||
def test_register_project_idempotent_overwrite_semantics(self, tmp_db):
|
||||
"""Zweiter Aufruf mit leerem git_url darf den ersten NICHT überschreiben."""
|
||||
db, _ = tmp_db
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project,
|
||||
)
|
||||
register_project("crm-system", git_url="https://first.git")
|
||||
register_project("crm-system", git_url="")
|
||||
row = db.conn.execute(
|
||||
"SELECT git_url FROM projects WHERE name = 'crm-system'"
|
||||
).fetchone()
|
||||
# COALESCE(NULLIF('', ''), projects.git_url) → behält first.git
|
||||
assert row["git_url"] == "https://first.git"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# resolve_project (4 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestResolveProject:
|
||||
def test_resolve_project_existing(self, tmp_db):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project, resolve_project,
|
||||
)
|
||||
pid = register_project("crm-system")
|
||||
assert resolve_project("crm-system") == pid
|
||||
|
||||
def test_resolve_project_missing_raises(self, tmp_db):
|
||||
"""Bug 1 Fix: KEIN Auto-Register mehr! Unbekannter Name → LookupError."""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
resolve_project,
|
||||
)
|
||||
with pytest.raises(LookupError):
|
||||
resolve_project("never-existed")
|
||||
# Bestätige: kein Eintrag in DB angelegt
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
db = PatternDB()
|
||||
n = db.conn.execute(
|
||||
"SELECT COUNT(*) as c FROM projects WHERE name = 'never-existed'"
|
||||
).fetchone()["c"]
|
||||
assert n == 0
|
||||
|
||||
def test_resolve_project_invalid_name_raises(self, tmp_db):
|
||||
"""Blacklist wirft VOR SELECT → ValueError (nicht LookupError)."""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
resolve_project,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
resolve_project("a0")
|
||||
with pytest.raises(ValueError):
|
||||
resolve_project("workdir")
|
||||
|
||||
def test_resolve_project_touches_last_active_at(self, tmp_db):
|
||||
db, _ = tmp_db
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project, resolve_project,
|
||||
)
|
||||
pid = register_project("crm-system")
|
||||
ts1 = db.conn.execute(
|
||||
"SELECT last_active_at FROM project_state WHERE project_id = ?", (pid,)
|
||||
).fetchone()["last_active_at"]
|
||||
time.sleep(1.1) # SQLite datetime() hat Sekundengranularität
|
||||
resolve_project("crm-system")
|
||||
ts2 = db.conn.execute(
|
||||
"SELECT last_active_at FROM project_state WHERE project_id = ?", (pid,)
|
||||
).fetchone()["last_active_at"]
|
||||
assert ts2 is not None and ts1 is not None
|
||||
assert ts2 > ts1, f"last_active_at should advance: ts1={ts1} ts2={ts2}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# get_project_id (1 Case)
|
||||
# =============================================================================
|
||||
|
||||
class TestGetProjectId:
|
||||
def test_get_project_id_missing_returns_none(self, tmp_db):
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
get_project_id,
|
||||
)
|
||||
assert get_project_id("never-existed") is None
|
||||
# Kein Side-Effect: kein Eintrag angelegt
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
db = PatternDB()
|
||||
n = db.conn.execute(
|
||||
"SELECT COUNT(*) as c FROM projects WHERE name = 'never-existed'"
|
||||
).fetchone()["c"]
|
||||
assert n == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Legacy-Migration (1 Case)
|
||||
# =============================================================================
|
||||
|
||||
class TestLegacyMigration:
|
||||
def test_legacy_row_with_invalid_name_is_archived(self, tmp_db):
|
||||
"""Projekt mit ungültigem Namen (UPPER, Punkt) wird durch Migrations-Lauf archiviert."""
|
||||
db, _ = tmp_db
|
||||
# 1. Direkt eine Row mit ungültigem Namen einfügen (simuliert Alt-Daten)
|
||||
cur = db.conn.execute(
|
||||
"INSERT INTO projects (name, description) VALUES (?, ?)",
|
||||
("OLD-PROJECT", "legacy"),
|
||||
)
|
||||
legacy_pid = int(cur.lastrowid)
|
||||
db.conn.execute(
|
||||
"""INSERT INTO project_state (project_id, status, phase)
|
||||
VALUES (?, 'active', 'intake')""",
|
||||
(legacy_pid,),
|
||||
)
|
||||
db.conn.commit()
|
||||
|
||||
# 2. Migrations-Run: rufe _archive_invalids-Logik aus utils/migrate_legacy_project_names
|
||||
# Wir importieren die Helfer-Funktionen direkt.
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
_validate_project_name,
|
||||
)
|
||||
# Iteriere projects und archiviere ungültige
|
||||
rows = db.conn.execute(
|
||||
"SELECT p.id, p.name, COALESCE(ps.status, '<none>') as status "
|
||||
"FROM projects p LEFT JOIN project_state ps ON ps.project_id = p.id"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
name = r["name"]
|
||||
try:
|
||||
_validate_project_name(name)
|
||||
except ValueError:
|
||||
if r["status"] != "archived":
|
||||
db.conn.execute(
|
||||
"""UPDATE project_state SET status = 'archived',
|
||||
phase = 'archived', last_active_at = datetime('now'),
|
||||
updated_at = datetime('now') WHERE project_id = ?""",
|
||||
(r["id"],),
|
||||
)
|
||||
db.conn.commit()
|
||||
|
||||
# 3. Prüfe: legacy_pid ist jetzt archived
|
||||
status = db.conn.execute(
|
||||
"SELECT status FROM project_state WHERE project_id = ?", (legacy_pid,)
|
||||
).fetchone()["status"]
|
||||
assert status == "archived", f"expected archived, got {status}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# End-to-End (1 Case)
|
||||
# =============================================================================
|
||||
|
||||
class TestE2ENextStep:
|
||||
def test_e2e_next_step_with_unknown_project_raises(self, tmp_db):
|
||||
"""NextStep-Tool mit unbekanntem Projekt wirft LookupError,
|
||||
ohne Phantom-Eintrag in DB zu erzeugen."""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
get_project_id,
|
||||
)
|
||||
# Vorbedingung: Projekt existiert nicht
|
||||
assert get_project_id("phantom-app") is None
|
||||
with pytest.raises(LookupError):
|
||||
get_project_id("phantom-app") # get_project_id wirft nicht → None
|
||||
# Stattdessen: simulate Tool-Verhalten: get_project_id+None+raise
|
||||
pid = get_project_id("phantom-app")
|
||||
if pid is None:
|
||||
raise LookupError(f"project 'phantom-app' is not registered")
|
||||
# Kein DB-Eintrag
|
||||
db = PatternDB()
|
||||
n = db.conn.execute(
|
||||
"SELECT COUNT(*) as c FROM projects WHERE name = 'phantom-app'"
|
||||
).fetchone()["c"]
|
||||
assert n == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# plan_mode_guard global (2 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestPlanModeGuardGlobal:
|
||||
def test_plan_mode_guard_set_get(self, tmp_db):
|
||||
"""Set implementation_allowed, lese via _get_plugin_mode zurück."""
|
||||
from usr.plugins.a0_software_orchestrator.tools.plan_mode_guard import (
|
||||
_get_plugin_mode, _set_plugin_mode, _DEFAULT_MODE,
|
||||
)
|
||||
# Init: default
|
||||
mode = _get_plugin_mode()
|
||||
assert mode["mode"] == _DEFAULT_MODE["mode"]
|
||||
# Set
|
||||
new_mode = {
|
||||
"mode": "implementation_allowed",
|
||||
"allowed_actions": ["code", "test", "commit"],
|
||||
"blocked_actions": ["deploy", "destroy", "push_to_main"],
|
||||
}
|
||||
_set_plugin_mode(new_mode)
|
||||
# Read back
|
||||
mode2 = _get_plugin_mode()
|
||||
assert mode2["mode"] == "implementation_allowed"
|
||||
assert "code" in mode2["allowed_actions"]
|
||||
# Nutzt plugin_settings.key (kein project_id, kein project_name nötig).
|
||||
# Migration 5 muss die Tabelle plugin_settings angelegt haben.
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
db = PatternDB()
|
||||
# plugin_settings-Tabelle existiert?
|
||||
tbl = db.conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_settings'"
|
||||
).fetchone()
|
||||
assert tbl is not None, "plugin_settings-Tabelle fehlt – Migration 5 nicht gelaufen"
|
||||
# Wert vorhanden unter 'orchestrator_mode'?
|
||||
row = db.conn.execute(
|
||||
"SELECT value_json FROM plugin_settings WHERE key = 'orchestrator_mode'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert json.loads(row[0])["mode"] == "implementation_allowed"
|
||||
|
||||
def test_plan_mode_guard_no_resolve_project_called(self, tmp_db):
|
||||
"""plan_mode_guard ruft KEIN resolve_project() auf (Bug 7 Fix)."""
|
||||
from usr.plugins.a0_software_orchestrator.tools import plan_mode_guard as pmg
|
||||
|
||||
with patch(
|
||||
"usr.plugins.a0_software_orchestrator.helpers.db_state_store.resolve_project"
|
||||
) as mock_resolve:
|
||||
# Mode lesen
|
||||
pmg._get_plugin_mode()
|
||||
# Mode setzen
|
||||
pmg._set_plugin_mode({
|
||||
"mode": "implementation_allowed",
|
||||
"allowed_actions": ["code"],
|
||||
"blocked_actions": ["destroy"],
|
||||
})
|
||||
# Tool.execute aufrufen (verschiedene Pfade)
|
||||
import asyncio
|
||||
tool = pmg.PlanModeGuard()
|
||||
asyncio.run(tool.execute(requested_action="code"))
|
||||
asyncio.run(tool.execute(new_mode="planning_only"))
|
||||
asyncio.run(tool.execute(requested_action="deploy"))
|
||||
|
||||
# KEIN Aufruf von resolve_project
|
||||
assert mock_resolve.call_count == 0, (
|
||||
f"resolve_project wurde {mock_resolve.call_count}x aufgerufen, "
|
||||
f"erwartet 0"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_mandatory_artifacts – 'todo' Bug-Fix (2 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestCheckMandatoryArtifacts:
|
||||
def test_check_mandatory_artifacts_session_done_with_no_open_todos(self, tmp_db):
|
||||
"""'Session beendet, 0 open todos' → todo: True (Bug-Fix).
|
||||
|
||||
Vorher wurde 'mind. 1 open todo' verlangt → blockierte
|
||||
Block-Compact fälschlich im Session-Ende-Zustand.
|
||||
Jetzt: Todo-Mechanismus verfügbar (Tabelle + Schema) reicht.
|
||||
"""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.compact_protocol import (
|
||||
check_mandatory_artifacts,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project,
|
||||
)
|
||||
db, _ = tmp_db
|
||||
# 1. Projekt registrieren (in tmp_db mit Migration 4 = orch_todos existiert)
|
||||
register_project("crm-system")
|
||||
# 2. Sicherstellen: 0 open todos (Session beendet)
|
||||
n = db.conn.execute(
|
||||
"SELECT COUNT(*) as c FROM orch_todos "
|
||||
"WHERE project_id = (SELECT id FROM projects WHERE name='crm-system') "
|
||||
"AND status='open'"
|
||||
).fetchone()["c"]
|
||||
assert n == 0, f"Test-Precondition: 0 open todos, got {n}"
|
||||
# 3. check_mandatory_artifacts aufrufen
|
||||
result = check_mandatory_artifacts("crm-system")
|
||||
# 4. 'todo' MUSS True sein (Bug-Fix)
|
||||
assert result["todo"] is True, (
|
||||
f"'todo' sollte True sein wenn orch_todos-Tabelle mit Schema "
|
||||
f"existiert (auch bei 0 open todos). Got: {result}"
|
||||
)
|
||||
# Sanity: andere Artefakte können False sein (kein worklog etc.)
|
||||
# aber das ist nicht der Punkt dieses Tests.
|
||||
|
||||
def test_check_mandatory_artifacts_no_orch_todos_table(self, tmp_db):
|
||||
"""Wenn orch_todos-Tabelle fehlt → todo: False (negativ-Test)."""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.compact_protocol import (
|
||||
check_mandatory_artifacts,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project,
|
||||
)
|
||||
db, _ = tmp_db
|
||||
register_project("crm-system")
|
||||
# orch_todos-Tabelle droppen (simuliert fehlende/alte DB)
|
||||
db.conn.execute("DROP TABLE orch_todos")
|
||||
db.conn.commit()
|
||||
# check_mandatory_artifacts aufrufen
|
||||
result = check_mandatory_artifacts("crm-system")
|
||||
# 'todo' MUSS False sein (Tabelle fehlt)
|
||||
assert result["todo"] is False, (
|
||||
f"'todo' sollte False sein wenn orch_todos-Tabelle fehlt. "
|
||||
f"Got: {result}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# block_compactor.py – entfernte lokale Helper (3 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestBlockCompactorCleanup:
|
||||
def test_block_compactor_no_local_resolve_name(self):
|
||||
"""_resolve_name wurde aus tools/block_compactor.py entfernt
|
||||
(basename-Fallback erzeugte potenziell ungültige Namen)."""
|
||||
import usr.plugins.a0_software_orchestrator.tools.block_compactor as bc
|
||||
assert not hasattr(bc, "_resolve_name"), (
|
||||
"_resolve_name sollte NICHT mehr in block_compactor definiert sein. "
|
||||
"Bug 4-7-3 Fix: basename-Fallback raus, project_name ist Pflicht."
|
||||
)
|
||||
|
||||
def test_block_compactor_uses_imported_check_mandatory_artifacts(self):
|
||||
"""check_mandatory_artifacts in block_compactor muss die importierte
|
||||
Version aus compact_protocol sein (gleiche Funktion-ID), nicht lokal
|
||||
überschrieben."""
|
||||
import usr.plugins.a0_software_orchestrator.tools.block_compactor as bc
|
||||
from usr.plugins.a0_software_orchestrator.helpers import compact_protocol as cp
|
||||
# bc.check_mandatory_artifacts MUSS cp.check_mandatory_artifacts sein
|
||||
assert bc.check_mandatory_artifacts is cp.check_mandatory_artifacts, (
|
||||
"check_mandatory_artifacts in block_compactor ist nicht die "
|
||||
"importierte compact_protocol-Version. Lokale Definition würde "
|
||||
"den 'todo'-Fix aus compact_protocol zunichte machen."
|
||||
)
|
||||
|
||||
def test_block_compactor_mandatory_artifacts_pass_with_full_setup(self, tmp_db):
|
||||
"""End-to-End: Projekt mit allen 5 mandatory artifacts →
|
||||
check_mandatory_artifacts liefert alles True (in tmp_db mit
|
||||
Migration 4 hat 'todo' nach Fix automatisch True)."""
|
||||
from usr.plugins.a0_software_orchestrator.helpers.compact_protocol import (
|
||||
check_mandatory_artifacts,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project, append_worklog, set_kv, set_status, add_next_step,
|
||||
)
|
||||
# 1. Projekt + alle 5 Artefakte anlegen
|
||||
register_project("crm-system")
|
||||
# tmp_db: get_project_id('crm-system') aufrufen für pid
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
db = PatternDB()
|
||||
pid_row = db.conn.execute(
|
||||
"SELECT id FROM projects WHERE name='crm-system'"
|
||||
).fetchone()
|
||||
pid = int(pid_row["id"])
|
||||
# worklog
|
||||
append_worklog(pid, "phase", "block-1", "agent", "summary", "details")
|
||||
# project_state (KV-Key)
|
||||
set_kv(pid, "project_state", {"phase": "intake"})
|
||||
# current_status
|
||||
set_status(pid, "working on it")
|
||||
# next_steps (pending)
|
||||
add_next_step(pid, "task 1")
|
||||
db.conn.commit()
|
||||
|
||||
# 2. check_mandatory_artifacts aufrufen
|
||||
result = check_mandatory_artifacts("crm-system")
|
||||
# 3. Alle 5 True
|
||||
missing = [k for k, v in result.items() if not v]
|
||||
assert not missing, f"Erwartet alle 5 True, aber fehlen: {missing} (Full result: {result})"
|
||||
assert result["todo"] is True, (
|
||||
"'todo' muss True sein (Bug-Fix in compact_protocol: "
|
||||
"Tabelle+Schema verfügbar, nicht 'mind. 1 open todo')"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# block_compactor – Option B Soft-Check (3 Cases)
|
||||
# =============================================================================
|
||||
|
||||
class TestBlockCompactorSoftCheck:
|
||||
def test_block_compactor_warns_but_writes_with_missing_artifacts(self, tmp_db):
|
||||
"""Refactor Option B: mandatory artifacts fehlen → WARN, nicht BLOCK.
|
||||
|
||||
Tool schreibt trotzdem (DB-Eintrag in orch_block_compact + worklog),
|
||||
message enthält 'mandatory_artifacts: 0/5 ⚠️' + 'mandatory artifacts incomplete'.
|
||||
"""
|
||||
import asyncio
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project, add_next_step,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.tools.block_compactor import (
|
||||
BlockCompactor,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
||||
db, _ = tmp_db
|
||||
register_project("crm-system")
|
||||
pid = int(db.conn.execute(
|
||||
"SELECT id FROM projects WHERE name='crm-system'"
|
||||
).fetchone()["id"])
|
||||
# next_steps anlegen, sonst triggert should_block_compact Z.6
|
||||
# (nicht Teil der Soft-Check-Refactor-Scope, bleibt BLOCK).
|
||||
add_next_step(pid, "dummy next step")
|
||||
db.conn.commit()
|
||||
# KEINE mandatory artifacts gefüllt (worklog, project_state-KV,
|
||||
# current_status fehlen; nur next_steps ist da)
|
||||
# block_compactor.execute() aufrufen
|
||||
bc = BlockCompactor()
|
||||
result = asyncio.run(bc.execute(
|
||||
project_name="crm-system",
|
||||
block_id="B-test-soft-check",
|
||||
block_summary="Test: fehlende mandatory artifacts sollen WARN sein",
|
||||
next_block="siehe worklog",
|
||||
decisions=[], open_questions=[], key_findings=[],
|
||||
estimated_context_tokens=7000, max_context_tokens=8000, # ratio=0.875 OK
|
||||
open_subagent_calls=0, quality_gate_passed=True,
|
||||
))
|
||||
msg = result.message
|
||||
# 1. NICHT 'BLOCK COMPACT BLOCKED'
|
||||
assert "BLOCK COMPACT BLOCKED" not in msg, (
|
||||
f"Soft-Check Bug: Tool hat BLOCKED statt WARN. message={msg!r}"
|
||||
)
|
||||
# 2. 'BLOCK COMPACT READY' (Erfolg)
|
||||
assert "BLOCK COMPACT READY" in msg, f"Erwartet READY, got: {msg!r}"
|
||||
# 3. 'mandatory artifacts incomplete' Warnung
|
||||
assert "mandatory artifacts incomplete" in msg, (
|
||||
f"Erwartet warning 'mandatory artifacts incomplete', got: {msg!r}"
|
||||
)
|
||||
# 4. mandatory_artifacts: <n>/5 + ⚠️ + missing-Liste
|
||||
# Setup hat: next_steps (add_next_step), todo (Tabelle via Migration),
|
||||
# project_state (register_project triggert phase-KV) = 3/5 True.
|
||||
# Fehlen: worklog, current_status.
|
||||
assert "⚠️" in msg and "missing:" in msg, (
|
||||
f"Erwartet '⚠️ missing: [...]', got: {msg!r}"
|
||||
)
|
||||
assert "worklog" in msg and "current_status" in msg, (
|
||||
f"Erwartet 'worklog' und 'current_status' in missing-Liste, got: {msg!r}"
|
||||
)
|
||||
# 5. DB-Eintrag in orch_block_compact wurde geschrieben
|
||||
row = db.conn.execute(
|
||||
"SELECT last_block_id FROM orch_block_compact WHERE project_id = ?",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
assert row is not None, (
|
||||
"Tool hat NICHT geschrieben trotz Option-B-Soft-Check. "
|
||||
"Erwartet orch_block_compact-Eintrag."
|
||||
)
|
||||
assert row["last_block_id"] == "B-test-soft-check"
|
||||
|
||||
def test_block_compactor_blocks_on_open_subagents(self, tmp_db):
|
||||
"""Hard-Block bleibt für open_subagent_calls > 0 (echter Datenverlust-Schutz)."""
|
||||
import asyncio
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project, append_worklog, set_kv, set_status, add_next_step,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.tools.block_compactor import (
|
||||
BlockCompactor,
|
||||
)
|
||||
db, _ = tmp_db
|
||||
register_project("crm-system")
|
||||
pid = int(db.conn.execute(
|
||||
"SELECT id FROM projects WHERE name='crm-system'"
|
||||
).fetchone()["id"])
|
||||
# ALLE mandatory artifacts füllen
|
||||
append_worklog(pid, "phase", "b1", "agent", "s", "d")
|
||||
set_kv(pid, "project_state", {"phase": "intake"})
|
||||
set_status(pid, "ok")
|
||||
add_next_step(pid, "task 1")
|
||||
db.conn.commit()
|
||||
# BlockCompactor mit open_subagent_calls=1
|
||||
bc = BlockCompactor()
|
||||
result = asyncio.run(bc.execute(
|
||||
project_name="crm-system",
|
||||
block_id="B-test-subagent",
|
||||
block_summary="subagent test",
|
||||
next_block="x",
|
||||
open_subagent_calls=1, quality_gate_passed=True,
|
||||
estimated_context_tokens=7000, max_context_tokens=8000,
|
||||
))
|
||||
msg = result.message
|
||||
assert "BLOCK COMPACT BLOCKED" in msg, f"Erwartet BLOCKED, got: {msg!r}"
|
||||
# Grund: subagent / open_subagent / pending subagent
|
||||
assert ("subagent" in msg.lower() or "subagent_calls" in msg.lower()), (
|
||||
f"Erwartet Hinweis auf subagent, got: {msg!r}"
|
||||
)
|
||||
|
||||
def test_block_compactor_blocks_on_quality_gate_fail(self, tmp_db):
|
||||
"""Hard-Block bleibt für quality_gate_passed=False (persistiert-Bug-Schutz)."""
|
||||
import asyncio
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
register_project, append_worklog, set_kv, set_status, add_next_step,
|
||||
)
|
||||
from usr.plugins.a0_software_orchestrator.tools.block_compactor import (
|
||||
BlockCompactor,
|
||||
)
|
||||
db, _ = tmp_db
|
||||
register_project("crm-system")
|
||||
pid = int(db.conn.execute(
|
||||
"SELECT id FROM projects WHERE name='crm-system'"
|
||||
).fetchone()["id"])
|
||||
# ALLE mandatory artifacts füllen
|
||||
append_worklog(pid, "phase", "b1", "agent", "s", "d")
|
||||
set_kv(pid, "project_state", {"phase": "intake"})
|
||||
set_status(pid, "ok")
|
||||
add_next_step(pid, "task 1")
|
||||
db.conn.commit()
|
||||
# BlockCompactor mit quality_gate_passed=False
|
||||
bc = BlockCompactor()
|
||||
result = asyncio.run(bc.execute(
|
||||
project_name="crm-system",
|
||||
block_id="B-test-quality",
|
||||
block_summary="quality test",
|
||||
next_block="x",
|
||||
open_subagent_calls=0, quality_gate_passed=False,
|
||||
estimated_context_tokens=7000, max_context_tokens=8000,
|
||||
))
|
||||
msg = result.message
|
||||
assert "BLOCK COMPACT BLOCKED" in msg, f"Erwartet BLOCKED, got: {msg!r}"
|
||||
assert "quality" in msg.lower(), (
|
||||
f"Erwartet Hinweis auf quality, got: {msg!r}"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
|
||||
"""External tool capability detection via dynamic plugin scanning."""
|
||||
import os
|
||||
import yaml
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
def _get_active_plugins() -> List[str]:
|
||||
"""Scan all plugin roots for active plugins."""
|
||||
active_plugins = []
|
||||
plugin_roots = ["/a0/plugins", "/a0/usr/plugins"]
|
||||
|
||||
for root in plugin_roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for plugin_name in os.listdir(root):
|
||||
plugin_dir = os.path.join(root, plugin_name)
|
||||
if not os.path.isdir(plugin_dir):
|
||||
continue
|
||||
|
||||
plugin_yaml = os.path.join(plugin_dir, "plugin.yaml")
|
||||
if not os.path.isfile(plugin_yaml):
|
||||
continue
|
||||
|
||||
# Check if plugin is active
|
||||
is_active = False
|
||||
if os.path.isfile(os.path.join(plugin_dir, ".toggle-1")):
|
||||
is_active = True
|
||||
elif os.path.isfile(os.path.join(plugin_dir, ".toggle-0")):
|
||||
is_active = False
|
||||
else:
|
||||
# No toggle files, check always_enabled
|
||||
try:
|
||||
with open(plugin_yaml, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
if config and config.get("always_enabled", False):
|
||||
is_active = True
|
||||
except (yaml.YAMLError) as e:
|
||||
pass
|
||||
|
||||
if is_active:
|
||||
active_plugins.append(plugin_name)
|
||||
|
||||
return active_plugins
|
||||
|
||||
|
||||
def generate_capability_report(config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Generate a capability report for all actively registered tools."""
|
||||
active_plugins = _get_active_plugins()
|
||||
report = {}
|
||||
|
||||
for name in active_plugins:
|
||||
report[name] = {
|
||||
"available": True,
|
||||
"fallback": "plugin is active",
|
||||
}
|
||||
|
||||
# Add a few standard tools not represented as plugins if they exist in config
|
||||
if config:
|
||||
tools_config = config.get("external_tools", {})
|
||||
for name, cfg in tools_config.items():
|
||||
if name not in report:
|
||||
report[name] = {
|
||||
"available": False,
|
||||
"fallback": cfg.get("fallback", "not available"),
|
||||
}
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def check_tool(tool_name: str) -> Dict[str, Any]:
|
||||
"""Return availability for one plugin/tool capability name."""
|
||||
active_plugins = _get_active_plugins()
|
||||
if tool_name in active_plugins:
|
||||
return {"available": True, "fallback": "plugin is active"}
|
||||
# Also match by declared tool name inside active plugin manifests.
|
||||
for plugin_name in active_plugins:
|
||||
plugin_dir = os.path.join("/a0/usr/plugins", plugin_name)
|
||||
plugin_yaml = os.path.join(plugin_dir, "plugin.yaml")
|
||||
try:
|
||||
with open(plugin_yaml, "r", encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
for tool in config.get("tools", []) or []:
|
||||
if tool.get("name") == tool_name:
|
||||
return {"available": True, "fallback": f"declared by plugin {plugin_name}"}
|
||||
except Exception:
|
||||
continue
|
||||
return {"available": False, "fallback": "not active or not declared"}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Validators for plugin configuration and project artifacts."""
|
||||
import os
|
||||
import yaml
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
def validate_config(config_path: str) -> Dict[str, Any]:
|
||||
"""Validate default_config.yaml and return errors."""
|
||||
errors = []
|
||||
if not os.path.exists(config_path):
|
||||
return {"valid": False, "errors": ["config file not found"]}
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
if not isinstance(config, dict):
|
||||
errors.append("config is not a mapping")
|
||||
except Exception as e:
|
||||
errors.append(f"YAML parse error: {e}")
|
||||
return {"valid": len(errors) == 0, "errors": errors}
|
||||
|
||||
|
||||
def validate_project_init(project_root: str) -> List[str]:
|
||||
"""Validate that a project has been properly initialized."""
|
||||
required_dirs = [
|
||||
".a0",
|
||||
"specs/current",
|
||||
"docs",
|
||||
"deploy",
|
||||
]
|
||||
missing = []
|
||||
for d in required_dirs:
|
||||
if not os.path.isdir(os.path.join(project_root, d)):
|
||||
missing.append(d)
|
||||
return missing
|
||||
Reference in New Issue
Block a user