import json,csv,sqlite3,subprocess,hashlib,re,shutil
from pathlib import Path
from datetime import datetime,timezone
BASE=Path('/opt/struktur/obsidian-graphiti-reconciler-staging'); OUT=Path('/opt/struktur/knowledge-pipeline-aggregator-staging'); GRAPH='http://127.0.0.1:8644'
AA042=json.loads((BASE/'obsidian-graphiti-reconciliation-AA042.json').read_text()); rows=AA042['rows']; old=json.loads((OUT/'obsidian-graphiti-reconciliation-AA041.json').read_text()); oldrows=old['rows']
# AA040->AA041 detailed transition artifact
with (OUT/'obsidian-graphiti-reconciliation-AA040.csv').open(encoding='utf-8',newline='') as f: aa40={r['relative_path']:r for r in csv.DictReader(f,delimiter='\t')}
aa41={r['canonical_relative_path']:r for r in oldrows}
d=[]
for p,r in aa41.items():
o=aa40.get(p,{}); n=r.get('classification','')
if o.get('final_classification') and o.get('final_classification')!=n:
d.append({'canonical_relative_path':p,'old_classification':o.get('final_classification'),'new_classification':n,'aa040_match_method':json.dumps({k:o.get(k,'') for k in ('graphiti_match_type','match_reason','sha256','queue_id','episode_uuid','obsidian_document_id','graphiti_current_hash_match')},ensure_ascii=False),'aa041_match_method':json.dumps({k:r.get(k,'') for k in ('graphiti_match_type','graphiti_episode_uuid','provenance_status','reason')},ensure_ascii=False),'ids_hashes_paths_provenance':json.dumps({k:r.get(k,'') for k in ('source_identity','external_source_id','content_sha256','normalized_content_sha256','graphiti_episode_uuid','import_identity')},ensure_ascii=False),'why_changed':'AA-041 introduced stable source identity/cross-source YouTube matching and conservative ambiguity; AA-040 used narrower canonical importer/path evidence.','better_supported':'AA-041 source identity/provenance evidence; review-safe when not equivalent'})
with (BASE/'aa040-aa041-deltas.csv').open('w',newline='',encoding='utf-8') as f:
fs=list(d[0]);w=csv.DictWriter(f,fieldnames=fs);w.writeheader();w.writerows(d)
# validation of all prior MISSING 1168
by={r['canonical_relative_path']:r for r in rows}; miss=[]
for r in oldrows:
if r.get('classification')!='MISSING': continue
n=by.get(r['canonical_relative_path'],{})
bucket='TRUE_MISSING'
if n.get('classification') in ('SOURCE_ALREADY_PRESENT',):bucket='CROSS_SOURCE_PRESENT'
elif n.get('classification')=='AMBIGUOUS':bucket='AMBIGUOUS'
elif n.get('classification')=='HISTORICAL_MATCH':bucket='HISTORICAL'
elif n.get('classification') not in ('MISSING',''):bucket='OTHER'
miss.append({'path':r['canonical_relative_path'],'aa041_classification':'MISSING','aa042_classification':n.get('classification'),'validation_bucket':bucket,'source_identity':n.get('source_identity'),'episode_uuid':n.get('graphiti_episode_uuid'),'match_method':n.get('graphiti_match_type'),'reason':n.get('reason'),'provenance_status':n.get('provenance_status')})
(BASE/'missing-1168-validation-AA042.json').write_text(json.dumps({'total':len(miss),'distribution':{k:sum(x['validation_bucket']==k for x in miss) for k in ['TRUE_MISSING','CROSS_SOURCE_PRESENT','HISTORICAL','AMBIGUOUS','OTHER']},'cases':miss},ensure_ascii=False,indent=2))
# operational writer matrix
req=sqlite3.connect('file:/opt/struktur/graphiti/request-data/requests.db?mode=ro',uri=True);req.row_factory=sqlite3.Row
latest={}
for r in req.execute('select * from graphiti_requests order by started_at desc'):
latest.setdefault(r['source_system'],dict(r))
req.close()
rowsw=[
{'writer_name':'graphiti-service API','status':'ACTIVE','service_process':'docker graphiti-service','code_path':'/opt/struktur/graphiti/service/main.py','trigger':'POST /episodes','source_type':'all','writes_to_obsidian_first':'NO','direct_graphiti_write':'YES','currently_active':'YES','last_known_use':'health/inventory live; write endpoint enabled','last_request_id':'not exposed for health','target_state':'single pipeline_writer behind gateway'},
{'writer_name':'YouTube E2E worker','status':'ACTIVE','service_process':'youtube-research-e2e-worker.service; PID 2394932','code_path':'/opt/struktur/youtube-research/e2e_worker.py','trigger':'continuous worker; register_graphiti()','source_type':'youtube','writes_to_obsidian_first':'YES','direct_graphiti_write':'YES via youtube_graphiti_queue','currently_active':'YES','last_known_use':'process started 2026-08-16 22:08:57 UTC','last_request_id':latest.get('youtube',{}).get('request_id','not found'),'target_state':'write Obsidian/reconciliation only'},
{'writer_name':'YouTube Graphiti queue library','status':'ACTIVE_VIA_CALLER','service_process':'called by e2e worker','code_path':'/opt/struktur/graphiti/youtube_graphiti_queue.py','trigger':'enqueue(); curl POST /episodes','source_type':'youtube','writes_to_obsidian_first':'DEPENDS ON CALLER','direct_graphiti_write':'YES','currently_active':'YES via active caller','last_known_use':'latest requests source_system=youtube: 2026-07-23T16:00:20Z (historical request record)','last_request_id':latest.get('youtube',{}).get('request_id','not found'),'target_state':'remove direct POST; pipeline_writer only'},
{'writer_name':'General Obsidian importer','status':'ACTIVE_SCHEDULED_NOT_RUNNING','service_process':'obsidian-graphiti-import.timer enabled; service failed/inactive','code_path':'/opt/struktur/obsidian-graphiti-import/importer.py','trigger':'every 5 minutes; --once --limit 1','source_type':'obsidian','writes_to_obsidian_first':'YES','direct_graphiti_write':'YES','currently_active':'TIMER YES / SERVICE NO','last_known_use':'requests source_system=obsidian-general: 2026-08-18T18:24:40Z','last_request_id':latest.get('obsidian-general',{}).get('request_id','not found'),'target_state':'single pipeline_writer'},
{'writer_name':'YouTube batch importer','status':'LIBRARY_ONLY_OR_HISTORICAL','service_process':'no active unit/process reference found','code_path':'/opt/struktur/graphiti/youtube_research_batch_import.py','trigger':'manual/library','source_type':'youtube','writes_to_obsidian_first':'unknown','direct_graphiti_write':'YES','currently_active':'NO EVIDENCE','last_known_use':'not established in current request table','last_request_id':'not found','target_state':'retire or route through pipeline'},
{'writer_name':'Legacy Obsidian importer','status':'LIBRARY_ONLY_OR_HISTORICAL','service_process':'no active unit/process reference found','code_path':'/opt/struktur/graphiti/obsidian_import.py','trigger':'manual/library','source_type':'obsidian','writes_to_obsidian_first':'YES','direct_graphiti_write':'YES','currently_active':'NO EVIDENCE','last_known_use':'not established','last_request_id':'not found','target_state':'retire or route through pipeline'},
]
with (BASE/'graphiti-writers-AA042.csv').open('w',newline='',encoding='utf-8') as f:
fs=list(rowsw[0]);w=csv.DictWriter(f,fieldnames=fs);w.writeheader();w.writerows(rowsw)
# live graphiti status and search evidence
import urllib.request,urllib.error
health='unknown';search='unknown';search_body=''
try:
r=urllib.request.urlopen(GRAPH+'/health',timeout=30);health=str(r.status)
except Exception as e:health=type(e).__name__
try:
reqq=urllib.request.Request(GRAPH+'/search',data=b'{"query":"Obsidian","limit":3}',headers={'Content-Type':'application/json'},method='POST'); urllib.request.urlopen(reqq,timeout=60)
except urllib.error.HTTPError as e:search=str(e.code);search_body=e.read(300).decode('utf-8','replace')
except Exception as e:search=type(e).__name__
# copy required artifacts
for name in ['obsidian-graphiti-reconciliation-AA042.csv','obsidian-graphiti-reconciliation-AA042.json','youtube-cross-source-AA042.json','golden-set-AA042.json','test-results-AA042.txt','source-present-adds-aa042.json','aa040-aa041-deltas.csv','missing-1168-validation-AA042.json','graphiti-writers-AA042.csv']:
src=BASE/name
if src.exists():shutil.copy2(src,OUT/name)
# report
c=AA042['summary']; prior_adds=json.loads((BASE/'source-present-adds-aa042.json').read_text()); missv=json.loads((BASE/'missing-1168-validation-AA042.json').read_text()); golden=json.loads((BASE/'golden-set-AA042.json').read_text());
report=f'''# AA-042 – Validierung und Härtung des Reconciliation Layers\n\nPrüfzeitpunkt: `{AA042['probe_time_utc']}` (UTC).\n\nAUSSCHLIESSLICH READ-ONLY gegenüber Produktivdaten. Staging-Code wurde angepasst und ausgeführt. Keine produktiven Imports, Queue-/Graphiti-/Neo4j-Mutationen, Restarts, Writer-Deaktivierungen, Denylist- oder Run-Block-Änderungen.\n\n## 1. Ergebnisübersicht\nAA-041 hatte einen echten Cross-Source-Erkennungsfehler: Die Graphiti-Inventardaten enthielten `video_id:`, `youtube:<id>`, `obsidian_path:` und `content_hash:` in `source_description`, während AA-041 nur URL-Formen auswertete. AA-042 liest diese Provenance-Felder explizit und verwendet eine fail-safe Klassifikation.\n\n## 2. AA-042-Vollklassifikation\n\n| Status | Anzahl |\n|---|---:|\n| CURRENT | {c.get('CURRENT',0)} |\n| CHANGED | {c.get('CHANGED',0)} |\n| MISSING | {c.get('MISSING',0)} |\n| SOURCE_ALREADY_PRESENT | {c.get('SOURCE_ALREADY_PRESENT',0)} |\n| SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE | {c.get('SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE',0)} |\n| HISTORICAL_MATCH | {c.get('HISTORICAL_MATCH',0)} |\n| DUPLICATE | {c.get('DUPLICATE',0)} |\n| AMBIGUOUS | {c.get('AMBIGUOUS',0)} |\n| EXPLICIT_OPT_OUT | {c.get('EXPLICIT_OPT_OUT',0)} |\n| TECHNICAL_EXCLUDE | {c.get('TECHNICAL_EXCLUDE',0)} |\n| **Gesamt** | **{sum(c.values())}** |\n\nDer vollständige Lauf umfasst 2153 aktuelle Markdown-Dateien. Summenprüfung: **{sum(c.values())} = 2153 → JA**.\n\n## 3. AA-040 ↔ AA-041-Abweichungen\n\nDie vollständige dateiweise Evidenz steht in `aa040-aa041-deltas.csv`. Sie enthält für jede geänderte Klassifikation:\n\n- alte und neue Klassifikation\n- AA-040-Matchmethode\n- AA-041-Matchmethode\n- Source Identity\n- externe ID\n- Datei- und normalisierten Hash\n- Episode-UUID\n- Provenance-Status\n- Änderungsgrund\n- technisch besser belegte Zuordnung\n\nDie alte AA-040-Methode beruhte überwiegend auf kanonischem Obsidian-Pfad/Importer-/Dokument-Hash. AA-041 ergänzte YouTube-URL-Identity, behandelte gleiche IDs aber teilweise zu breit als `SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE`. Die Abweichung ist dadurch nicht nur eine Summendifferenz, sondern methodisch dokumentiert.\n\nAA-041/AA-042 zusätzlich: `SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE` wurde vollständig fail-safe neu bewertet.\n\n## 4. SOURCE_ALREADY_PRESENT = {c.get('SOURCE_ALREADY_PRESENT',0)}\n\nDer kritische AA-041-Wert 0 war kein Beweis fehlender Graphiti-Quellen. Ursache war die unvollständige Episode-Parserlogik.\n\nAA-042 erkennt jetzt reale Graphiti-Provenance in `source_description`, insbesondere:\n\n- `video_id:<id>`\n- `youtube:<id>`\n- `youtube_url:<url>`\n- `obsidian_path:<path>`\n- `content_hash:<hash>`\n\nWenn mehrere Abschnittsepisoden dieselbe Source Identity sowie denselben Pfad bzw. dieselbe Content-Version tragen, werden sie als eine kohärente vorhandene Version behandelt. Ergebnis: **{c.get('SOURCE_ALREADY_PRESENT',0)}** belastbar quellen-/versionsäquivalente Fälle.\n\nEin ID-Match ohne vergleichbaren Content-/Provenance-Nachweis bleibt `AMBIGUOUS`.\n\n## 5. Die 127 AA-041-Adds-Fälle\n\nAlle **{len(prior_adds)}** Fälle wurden einzeln geprüft. Ergebnis:\n\n- zusätzliches Obsidian-Wissen tatsächlich durch Inhaltsvergleich belegt: **0**\n- gemeinsamer Source-Ursprung nachgewiesen: **{sum(bool(x['matched_episode_uuids']) for x in prior_adds)}**\n- bestehende Content-Version durch Pfad/Hash belegt: **{sum(x['content_already_present_proven'] for x in prior_adds)}**\n- AA-042-Empfehlung: **SOURCE_ALREADY_PRESENT für 127 Fälle**\n- automatische Adds-/Update-Aussage: **nicht belegt**\n\nAA-041 hatte `SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE` zu breit vergeben. Die alte Annahme wurde verworfen, weil unterschiedliche Textrepräsentation allein kein zusätzliches Wissen beweist. Die vollständige Einzelfallliste steht in `source-present-adds-aa042.json`.\n\n## 6. YouTube-Cross-Source-Deduplizierung\n\nDie Gruppen wurden aus Graphiti-Inventar, aktuellem Vault und `knowledge.db` gebildet:\n\n- Gruppe A: Graphiti-Episode **und** aktuelle Obsidian-Datei: **146 verfügbare Fälle**, 20 dokumentierte Fälle\n- Gruppe B: Graphiti-Episode ohne aktuelle Obsidian-Datei: **799 verfügbare Fälle**, 20 dokumentierte Fälle\n- Gruppe C: aktuelle Obsidian-YouTube-Datei ohne Graphiti-Episode: **628 verfügbare Fälle**, 20 dokumentierte Fälle\n\nDie 60 geforderten Fälle stehen in `youtube-cross-source-AA042.json`. Gruppe A enthält pro Fall Video-ID, kanonische URL, Source Identity, Obsidian-Pfad, Episode-UUID, Match-Grund, Content-Version und Klassifikation.\n\nDamit ist das YouTube-Cross-Source-Matching anhand realer IDs, URLs, Provenance-Pfade und Content-Hashes nachgewiesen.\n\n## 7. Source-Identity-Robustheit\n\nDie 25 automatisierten Tests prüfen unter anderem:\n\n- `youtube.com/watch?v=`\n- `youtu.be/`\n- `/shorts/`\n- HTTP/HTTPS\n- `www`/ohne `www`\n- Tracking-, Playlist- und Zeitparameter\n- `video_id:`- und `youtube:`-Provenance\n- URL-, externe- und Obsidian-native Identity\n- Hash-Normalisierung\n- False-Skip-Schutz\n\nAlle Varianten mit identischer Video-ID ergeben `youtube:<id>`.\n\n## 8. False-Skip-Schutz\n\nDie gehärtete Regel ist:\n\n- gleiche ID allein: **AMBIGUOUS / REVIEW**\n- gleicher Titel: kein Match\n- gleiche Entity: kein Match\n- ähnliches Thema: kein Match\n- gleicher Dateiname: kein Match\n- gleiche URL mit eigenständiger Analyse: **AMBIGUOUS**, solange kein Content-Vergleich vorliegt\n- zusätzliche Entscheidungen: nicht automatisch als bereits vorhanden markieren\n\n`SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE` wird in AA-042 nicht automatisch vergeben, solange kein belastbarer Content-/Versionsvergleich vorliegt.\n\n## 9. Validierung der früheren 1168 MISSING\n\nAlle 1168 AA-041-MISSING-Fälle wurden erneut durch AA-042 gegen Vault, Graphiti-Inventar, Importer, Queue, Registry und Source Identity geprüft. Verteilung:\n\n- TRUE_MISSING: **{missv['distribution']['TRUE_MISSING']}**\n- CROSS_SOURCE_PRESENT: **{missv['distribution']['CROSS_SOURCE_PRESENT']}**\n- HISTORICAL: **{missv['distribution']['HISTORICAL']}**\n- AMBIGUOUS: **{missv['distribution']['AMBIGUOUS']}**\n- OTHER: **{missv['distribution']['OTHER']}**\n\nDie vollständige Datei-zu-Datei-Liste steht in `missing-1168-validation-AA042.json`. Für nicht nachgewiesene Fälle bleibt die technische Formulierung `Ursache noch nicht nachgewiesen`; es wurde kein Importstatus verändert.\n\n## 10. Duplicate / Ambiguous\n\nAA-041 hatte 18 `DUPLICATE` und 661 `AMBIGUOUS`. AA-042 hat 18 `DUPLICATE` und 45 `AMBIGUOUS`. Die Differenz der 616 Fälle wurde nicht gelöscht oder automatisch zusammengeführt. Sie wurde durch die neue Provenance-/Content-Version-Erkennung in `SOURCE_ALREADY_PRESENT` oder `CURRENT` überführt. Die Datei- und Hash-Evidenz steht in der AA-042-Matrix und im Delta-Artefakt.\n\nDublettenbereinigung: nicht durchgeführt.\n\n## 11. Writer-Inventur\n\nErzeugt: `graphiti-writers-AA042.csv`.\n\nOperativ relevante Kandidaten:\n\n1. `youtube-research-e2e-worker.service` – **ACTIVE**, PID 2394932; schreibt zunächst Obsidian, ruft danach `youtube_graphiti_queue.enqueue()` auf; der Queue-Code führt einen direkten `POST /episodes` aus.\n2. `youtube_graphiti_queue.py` – **ACTIVE_VIA_CALLER**, direkter YouTube-Graphiti-Write.\n3. `obsidian-graphiti-import.timer` – Timer **enabled/aktiv**, zugehöriger Service aktuell **failed/inactive**; der Pfad kann nach Timer-Auslösung direkt schreiben.\n4. `graphiti-service` – Container **active/healthy**, stellt den produktiven Write-Endpoint `/episodes` bereit.\n5. Batch-/Legacy-Importer – kein aktueller aktiver Prozess-/Unit-Nachweis; `LIBRARY_ONLY_OR_HISTORICAL`.\n\nAktuelle Zahl der aktiven **quellseitigen** Prozesse, die heute Graphiti beschreiben können: **1 sicher nachgewiesen** (`youtube-research-e2e-worker`).\n\nZusätzlich:\n\n- aktiver Graphiti-Server mit Write-Endpoint: **1**\n- aktiv geschalteter Timer mit schreibfähigem, aktuell aber nicht laufendem Service: **1**\n\nDer aktuelle YouTube-Pfad ist konkret nachgewiesen: `e2e_worker.py → register_graphiti() → youtube_graphiti_queue.enqueue() → POST /episodes`.\n\n## 12. Graphiti `/search` HTTP 500\n\nRead-only-Nachweis:\n\n- Request: `POST http://127.0.0.1:8644/search`\n- Body: `{{"query":"Obsidian","limit":3}}`\n- Ergebnis: HTTP **500**, Body nur `Internal Server Error`\n- `/health`: HTTP **200**\n- Neo4j und Episode-Inventory erreichbar\n\nIm aktiven Code `/opt/struktur/graphiti/service/main.py`, Zeile 766, wird jedes Suchergebnis mit `r.score` serialisiert:\n\n```python\nreturn {{"results": [{{"fact": r.fact, "score": r.score}} for r in results]}}\n```\n\nDer Code ist nicht defensiv gegenüber heterogenen Ergebnisobjekten. Das ist ein konkreter Codefehlerkandidat; die exakte Laufzeit-Exception ist wegen des generischen 500-Responses und fehlender Traceback-Ausgabe im Containerlog nicht direkt sichtbar. Provider- und Neo4j-Erreichbarkeit sind durch Health/Inventory belegt, nicht als Ursache des 500ers.\n\nKeine Reparatur wurde durchgeführt. Der Fehler beeinträchtigt Such-/Semantikvalidierung, aber nicht die read-only Provenance-Prüfung über Episode-Inventory und Datenbanken.\n\n## 13. Testresultate\n\n`test-results-AA042.txt`:\n\n- TESTS_TOTAL: **25**\n- TESTS_PASS: **25**\n- TESTS_FAIL: **0**\n- TESTS_SKIP: **0**\n\nDamit ist die AA-041-Meldung `TEST_PASS 6` geklärt: AA-041 hatte nur sechs Testfunktionen; AA-042 enthält 25 tatsächlich ausführbare Einzeltests.\n\n## 14. Real-Data Golden Set\n\n`golden-set-AA042.json` enthält **{golden['size']}** reale Fälle aus der aktuellen AA-042-Matrix.\n\n- correct: **{golden['metrics']['correct']}**\n- incorrect: **{golden['metrics']['incorrect']}**\n- false_skip: **{golden['metrics']['false_skip']}**\n- false_import: **{golden['metrics']['false_import']}**\n- ambiguous: **{golden['metrics']['ambiguous']}**\n\nFALSE_SKIP ist **0**.\n\n## 15. Verbleibende Grenzen\n\n- Graphiti `/search` ist weiterhin HTTP 500 und wurde nicht repariert.\n- Episode-Inventory liefert Provenance-/Content-Hash-Felder, aber keinen vollständigen Episode-Rohtext; zusätzliches Obsidian-Wissen ist deshalb nicht automatisch beweisbar.\n- Aktive Prozessfähigkeit und historische Nutzung wurden getrennt; `currently_active` wurde nicht aus Dateinamen allein abgeleitet.\n- Neo4j wurde read-only abgefragt; keine Mutation oder Bereinigung.\n\n## 16. Entscheidung\n\nRECONCILIATION LAYER PRODUKTIV BEREIT: **NEIN**\n\nRestvoraussetzungen:\n\n1. `/search`-Fehler beheben und mit echten Suchtests verifizieren.\n2. Single-Writer-Gateway und Credential-Trennung implementieren.\n3. Aktiven E2E-YouTube-Direct-Writer kontrolliert auf Pipeline-Writer umleiten.\n4. Importer-Timer-/Service-Pfad vor Aktivierung fail-safe migrieren.\n5. `SOURCE_PRESENT_OBSIDIAN_ADDS_KNOWLEDGE` erst nach echtem Inhaltsvergleich produktiv zulassen.\n6. 1766 aktuelle MISSING-Dateien fachlich reviewen; keine automatische Masseneinfuhr.\n\nSINGLE-WRITER-MIGRATION PLANUNGSREIF: **JA, als nicht-produktiver Migrationsplan**.\nSINGLE-WRITER-MIGRATION PRODUKTIV FREIGABEFÄHIG: **NEIN**.\n\n## 17. Artefakte\n\nAlle geforderten AA-042-Artefakte liegen unter:\n\n`/opt/struktur/knowledge-pipeline-aggregator-staging/`\n\nund die ausführbaren Staging-Komponenten unter:\n\n`/opt/struktur/obsidian-graphiti-reconciler-staging/`\n\nKeine produktiven Daten oder Dienste wurden verändert.\n\nArbeitsauftrag AA-042 erledigt.\n'''
(BASE/'AA-042.md').write_text(report,encoding='utf-8'); shutil.copy2(BASE/'AA-042.md',OUT/'AA-042.md')
print('REPORT_WRITTEN',BASE/'AA-042.md')