From a9b3e2f0e29a1454d9230373a1b7e90cd284265b Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 26 Jan 2026 10:51:12 +0100 Subject: [PATCH] Implement Chain Workbench and Vault Triage features in Mindnet plugin - Introduced new workflows for Chain Workbench and Vault Triage, enhancing user capabilities for managing template matches and identifying chain gaps. - Added commands for opening the Chain Workbench and scanning the vault for chain gaps, improving the overall functionality of the plugin. - Updated documentation to include detailed instructions for the new workflows, ensuring users can effectively utilize the features. - Enhanced the UI for both the Chain Workbench and Vault Triage Scan, providing a more intuitive user experience. - Implemented tests for the new functionalities to ensure reliability and accuracy in various scenarios. --- docs/01_Benutzerhandbuch.md | 50 ++ .../03_chain_identification_and_matching.md | 393 +++++++++ docs/03_Entwicklerhandbuch.md | 4 +- docs/08_Testing_Chain_Workbench.md | 309 +++++++ docs/09_Workbench_Analysis_Basis.md | 278 +++++++ docs/10_Workbench_Findings_Integration.md | 342 ++++++++ docs/readme.md | 10 + src/commands/chainWorkbenchCommand.ts | 112 +++ src/commands/vaultTriageScanCommand.ts | 50 ++ src/main.ts | 71 ++ src/tests/workbench/statusCalculator.test.ts | 324 ++++++++ src/tests/workbench/todoGenerator.test.ts | 401 +++++++++ src/tests/workbench/zoneDetector.test.ts | 210 +++++ src/ui/ChainWorkbenchModal.ts | 678 +++++++++++++++ src/ui/EdgeTypeChooserModal.ts | 57 +- src/ui/VaultTriageScanModal.ts | 362 ++++++++ src/workbench/interviewOrchestration.ts | 369 +++++++++ src/workbench/linkExistingAction.ts | 78 ++ src/workbench/statusCalculator.ts | 99 +++ src/workbench/todoGenerator.ts | 491 +++++++++++ src/workbench/types.ts | 233 ++++++ src/workbench/vaultTriageScan.ts | 245 ++++++ src/workbench/workbenchBuilder.ts | 108 +++ src/workbench/writerActions.ts | 780 ++++++++++++++++++ src/workbench/zoneDetector.ts | 160 ++++ 25 files changed, 6202 insertions(+), 12 deletions(-) create mode 100644 docs/02_concepts/03_chain_identification_and_matching.md create mode 100644 docs/08_Testing_Chain_Workbench.md create mode 100644 docs/09_Workbench_Analysis_Basis.md create mode 100644 docs/10_Workbench_Findings_Integration.md create mode 100644 src/commands/chainWorkbenchCommand.ts create mode 100644 src/commands/vaultTriageScanCommand.ts create mode 100644 src/tests/workbench/statusCalculator.test.ts create mode 100644 src/tests/workbench/todoGenerator.test.ts create mode 100644 src/tests/workbench/zoneDetector.test.ts create mode 100644 src/ui/ChainWorkbenchModal.ts create mode 100644 src/ui/VaultTriageScanModal.ts create mode 100644 src/workbench/interviewOrchestration.ts create mode 100644 src/workbench/linkExistingAction.ts create mode 100644 src/workbench/statusCalculator.ts create mode 100644 src/workbench/todoGenerator.ts create mode 100644 src/workbench/types.ts create mode 100644 src/workbench/vaultTriageScan.ts create mode 100644 src/workbench/workbenchBuilder.ts create mode 100644 src/workbench/writerActions.ts create mode 100644 src/workbench/zoneDetector.ts diff --git a/docs/01_Benutzerhandbuch.md b/docs/01_Benutzerhandbuch.md index 8aa4cc0..0dc3305 100644 --- a/docs/01_Benutzerhandbuch.md +++ b/docs/01_Benutzerhandbuch.md @@ -264,6 +264,54 @@ Inhalt mit Links: [[Note1]] und [[Note2]] - Verfügbare Actions auswählen - Automatische Behebung +### Workflow 5: Chain Workbench verwenden (Neu in 0.5.x) + +1. **Section öffnen:** + - Cursor in relevante Section positionieren + +2. **Chain Workbench öffnen:** + - Command: "Mindnet: Chain Workbench (Current Section)" + - Modal zeigt alle Template Matches + +3. **Matches analysieren:** + - Filter nach Status (complete, near_complete, partial, weak) + - Suche nach Template-Name + - Match auswählen → Details anzeigen + +4. **Todos bearbeiten:** + - Todo auswählen + - Action wählen (z.B. "Insert Edge", "Create Note", "Promote Candidate") + - Apply → Workbench aktualisiert sich automatisch + +5. **Wiederholen:** + - Weitere Todos bearbeiten + - Status verbessert sich nach jedem Apply + +### Workflow 6: Vault Triage Scan (Neu in 0.5.x) + +1. **Scan starten:** + - Command: "Mindnet: Scan Vault for Chain Gaps" + - Modal öffnet sich + +2. **Scan durchführen:** + - Klicke "Start Scan" + - Progress wird angezeigt + - Scan kann unterbrochen werden + +3. **Backlog durchsuchen:** + - Filter nach Status, Gap-Typen + - Suche nach File, Heading, Template + - Sortierung: near_complete zuerst + +4. **Items bearbeiten:** + - "Open Workbench" → öffnet Datei und Workbench + - "Deprioritize" → markiert als nicht fokussiert + - Items bleiben im Backlog (werden gefiltert) + +5. **Resume:** + - Scan kann fortgesetzt werden (wenn unterbrochen) + - State wird gespeichert + ### Workflow 3: Unresolved Link → Note erstellen 1. **Link erstellen:** @@ -323,6 +371,8 @@ Inhalt mit Links: [[Note1]] und [[Note2]] | Command | Beschreibung | Wann verwenden | |---------|--------------|----------------| | **Mindnet: Inspect Chains (Current Section)** | Analysiert kausale Ketten | Chain-Analyse durchführen | +| **Mindnet: Chain Workbench (Current Section)** | Workbench für alle Template Matches mit Todos | Chain-Gaps identifizieren und beheben | +| **Mindnet: Scan Vault for Chain Gaps** | Scannt gesamten Vault nach Chain-Gaps | Vault-weites Backlog erstellen | | **Mindnet: Fix Findings (Current Section)** | Behebt Findings automatisch | Findings automatisch beheben | | **Mindnet: Validate current note** | Validiert aktuelle Note (Lint) | Note auf Fehler prüfen | diff --git a/docs/02_concepts/03_chain_identification_and_matching.md b/docs/02_concepts/03_chain_identification_and_matching.md new file mode 100644 index 0000000..960da2e --- /dev/null +++ b/docs/02_concepts/03_chain_identification_and_matching.md @@ -0,0 +1,393 @@ +# Chain-Identifikation und Template Matching + +## Übersicht + +Das System identifiziert und füllt Chains durch einen mehrstufigen Prozess: + +1. **Graph-Indexierung**: Erfassung aller Edges (Kanten) im lokalen Subgraph +2. **Candidate-Node-Sammlung**: Identifikation potenzieller Knoten für Template-Slots +3. **Template Matching**: Backtracking-Algorithmus zur optimalen Slot-Zuordnung +4. **Link-Validierung**: Prüfung, ob Edges zwischen zugewiesenen Slots existieren +5. **Scoring & Confidence**: Bewertung der Match-Qualität + +--- + +## 1. Graph-Indexierung + +### Edge-Erfassung + +Der **Chain Inspector** (`src/analysis/chainInspector.ts`) erfasst alle Edges im lokalen Subgraph: + +- **Current Section**: Die aktuelle Section (file + heading) +- **Incoming Edges**: Edges, die zur aktuellen Section zeigen +- **Outgoing Edges**: Edges, die von der aktuellen Section ausgehen +- **Neighbor Notes**: Verbundene Notes werden geladen, um den Subgraph zu erweitern + +### Edge-Scopes + +Edges können drei Scopes haben: + +- **`section`**: Section-spezifisch (`[[Note#Heading]]`) +- **`note`**: Note-weit (`[[Note]]` ohne Heading) +- **`candidate`**: In "## Kandidaten" Zone + +### Filterung + +Edges werden basierend auf `InspectorOptions` gefiltert: + +- `includeNoteLinks`: Ob Note-Scope Edges einbezogen werden +- `includeCandidates`: Ob Candidate Edges einbezogen werden +- `maxDepth`: Maximale Tiefe für Path-Traversal + +--- + +## 2. Candidate-Node-Sammlung + +### Prozess (`buildCandidateNodes`) + +Für jeden Edge im Subgraph werden **Candidate Nodes** erstellt: + +1. **Source Nodes**: Alle Knoten, von denen Edges ausgehen +2. **Target Nodes**: Alle Knoten, zu denen Edges zeigen +3. **Note-Type-Extraktion**: Frontmatter `type:` wird aus jeder Note extrahiert +4. **Deduplizierung**: Gleiche Knoten (file + heading) werden nur einmal erfasst + +### Node-Repräsentation + +```typescript +interface CandidateNode { + nodeKey: { + file: string; + heading: string | null; + }; + noteType: string; // z.B. "insight", "decision", "experience" +} +``` + +### Maximale Anzahl + +Standardmäßig werden maximal **30 Candidate Nodes** gesammelt (konfigurierbar). + +--- + +## 3. Template Matching + +### Template-Definition (`chain_templates.yaml`) + +Jedes Template definiert: + +- **Slots**: Positionen in der Chain mit erlaubten Note-Types +- **Links**: Erwartete Verbindungen zwischen Slots mit erlaubten Edge-Roles + +**Beispiel: `loop_learning`** + +```yaml +slots: + - id: experience + allowed_node_types: [experience, journal, event] + - id: learning + allowed_node_types: [insight, principle, value, belief, skill, trait] + - id: behavior + allowed_node_types: [habit, decision, task] + - id: feedback + allowed_node_types: [experience, journal, event, state] + +links: + - from: experience + to: learning + allowed_edge_roles: [causal, provenance, influences] + - from: learning + to: behavior + allowed_edge_roles: [influences, enables_constraints, causal] + - from: behavior + to: feedback + allowed_edge_roles: [causal, influences] +``` + +### Backtracking-Algorithmus (`findBestAssignment`) + +Der Algorithmus findet die **beste Slot-Zuordnung** durch systematisches Ausprobieren: + +1. **Slot-Filterung**: Für jeden Slot werden nur Candidate Nodes gefiltert, die den `allowed_node_types` entsprechen +2. **Backtracking**: Rekursives Durchprobieren aller möglichen Zuordnungen +3. **Distinct Nodes**: Jeder Knoten kann nur einmal zugeordnet werden (wenn `distinct_nodes: true`) +4. **Scoring**: Jede vollständige Zuordnung wird bewertet +5. **Best Match**: Die Zuordnung mit dem höchsten Score wird zurückgegeben + +### Slot-Zuordnung + +```typescript +function backtrack(assignment: Map, slotIndex: number) { + // Wenn alle Slots zugeordnet sind: + if (slotIndex >= slots.length) { + const result = scoreAssignment(...); // Bewerte Zuordnung + if (result.score > bestScore) { + bestMatch = result; // Speichere besten Match + } + return; + } + + // Probiere jeden passenden Candidate für diesen Slot + for (const candidate of slotCandidates.get(slot.id)) { + if (!alreadyAssigned(candidate)) { + assignment.set(slot.id, candidate); + backtrack(assignment, slotIndex + 1); // Rekursiv weiter + assignment.delete(slot.id); // Backtrack + } + } + + // Auch: Slot leer lassen (für unvollständige Chains) + backtrack(assignment, slotIndex + 1); +} +``` + +--- + +## 4. Link-Validierung + +### Edge-Suche (`findEdgeBetween`) + +Für jedes Template-Link wird geprüft, ob ein Edge zwischen den zugewiesenen Slots existiert: + +1. **Node-Keys**: `fromKey = "file:heading"`, `toKey = "file:heading"` +2. **Edge-Suche**: Durchsuche `allEdges` nach passendem Edge +3. **Canonicalisierung**: Edge-Typ wird auf Canonical gemappt (via `edge_vocabulary.md`) +4. **Role-Mapping**: Canonical Edge-Typ wird auf Role gemappt (via `chain_roles.yaml`) +5. **Role-Validierung**: Prüfe, ob Edge-Role in `allowed_edge_roles` enthalten ist + +### Role-Evidence + +Wenn ein Link erfüllt ist, wird **Role Evidence** gespeichert: + +```typescript +roleEvidence.push({ + from: "learning", // Slot-ID (nicht Dateipfad!) + to: "behavior", // Slot-ID + edgeRole: "causal", // Role aus chain_roles.yaml + rawEdgeType: "resulted_in" // Original Edge-Typ im Vault +}); +``` + +**Wichtig**: `roleEvidence` verwendet **Slot-IDs**, nicht Dateipfade! + +--- + +## 5. Scoring & Confidence + +### Scoring (`scoreAssignment`) + +**Slot-Scoring**: +- Jeder zugewiesene Slot: **+2 Punkte** + +**Link-Scoring**: +- Erfüllter Link (mit erlaubter Role): **+10 Punkte** +- Fehlender Link (wenn `required_links: true`): **-5 Punkte** +- Falsche Role (wenn `required_links: true`): **-5 Punkte** + +### Confidence-Berechnung + +Die **Confidence** wird nach dem Matching berechnet: + +1. **`weak`**: Wenn `slotsComplete === false` (fehlende Slots) +2. **`confirmed`**: Wenn `slotsComplete === true` UND `linksComplete === true` UND mindestens eine causal-ish Role Evidence +3. **`plausible`**: Sonst (Slots vollständig, aber Links unvollständig oder keine causal Roles) + +**Causal-ish Roles**: `["causal", "influences", "enables_constraints"]` (konfigurierbar in `chain_templates.yaml`) + +### Completeness + +- **`slotsComplete`**: `missingSlots.length === 0` +- **`linksComplete`**: `satisfiedLinks === requiredLinks` + +--- + +## 6. Template-Matching-Profile + +### Profile-Definition + +Profile steuern die Matching-Strenge: + +**`discovery`** (schreibfreundlich): +```yaml +required_links: false # Links sind optional +min_slots_filled_for_gap_findings: 2 +min_score_for_gap_findings: 8 +``` + +**`decisioning`** (strikt): +```yaml +required_links: true # Links sind Pflicht +min_slots_filled_for_gap_findings: 3 +min_score_for_gap_findings: 18 +``` + +### Profile-Auflösung + +1. **Settings**: Plugin-Einstellung `templateMatchingProfile` +2. **Template**: Template-spezifische `matching.required_links` +3. **Defaults**: `defaults.matching.required_links` + +--- + +## 7. Beispiel: Loop Learning Match + +### Input + +- **Current Section**: `Tests/03_insight_transformation.md#Kern` +- **Edges im Subgraph**: 8 Edges (1 current, 7 neighbors) +- **Candidate Nodes**: 4 Nodes (experience, learning, behavior, feedback) + +### Template + +```yaml +name: loop_learning +slots: [experience, learning, behavior, feedback] +links: + - experience → learning + - learning → behavior + - behavior → feedback +``` + +### Matching-Prozess + +1. **Slot-Zuordnung** (Backtracking): + - `experience` → `Tests/02_event_trigger_detail.md#Detail` (noteType: `event`) + - `learning` → `Tests/03_insight_transformation.md#Kern` (noteType: `insight`) + - `behavior` → `Tests/04_decision_outcome.md#Entscheidung` (noteType: `decision`) + - `feedback` → `Tests/01_experience_trigger.md#Kontext` (noteType: `experience`) + +2. **Link-Validierung**: + - `experience → learning`: ❌ Kein Edge gefunden + - `learning → behavior`: ✅ Edge `resulted_in` mit Role `causal` gefunden + - `behavior → feedback`: ❌ Kein Edge gefunden + +3. **Scoring**: + - Slots: 4 × 2 = **8 Punkte** + - Links: 1 × 10 = **10 Punkte** + - **Gesamt: 18 Punkte** + +4. **Resultat**: + ```json + { + "templateName": "loop_learning", + "score": 18, + "slotsComplete": true, + "linksComplete": false, + "satisfiedLinks": 1, + "requiredLinks": 3, + "confidence": "plausible", + "roleEvidence": [ + { + "from": "learning", + "to": "behavior", + "edgeRole": "causal", + "rawEdgeType": "resulted_in" + } + ] + } + ``` + +--- + +## 8. Wichtige Konzepte + +### Slot-IDs vs. Dateipfade + +- **Slot-IDs**: Template-interne Bezeichner (`"learning"`, `"behavior"`) +- **Dateipfade**: Vault-Pfade (`"Tests/03_insight_transformation.md"`) +- **roleEvidence** verwendet Slot-IDs, nicht Dateipfade! + +### Canonicalisierung + +- **Intern**: Edge-Typen werden auf Canonical gemappt (für Analyse) +- **Vault**: Original Edge-Typen (Aliase) bleiben unverändert +- **Schreiben**: Plugin schreibt keine Canonicals, nur User-gewählte Typen + +### Distinct Nodes + +Wenn `distinct_nodes: true`: +- Jeder Knoten kann nur **einmal** pro Template zugeordnet werden +- Verhindert zirkuläre Zuordnungen + +### Edge-Target-Resolution + +Edges können verschiedene Pfad-Formate verwenden: +- Vollständig: `Tests/03_insight_transformation.md` +- Basename: `03_insight_transformation` +- Wikilink: `[[03_insight_transformation]]` + +Das System normalisiert alle Formate für konsistente Matching. + +--- + +## 9. Integration mit Chain Workbench + +Der **Chain Workbench** nutzt die Template Matches, um: + +1. **Todos zu generieren**: + - `missing_slot`: Fehlende Slot-Zuordnungen + - `missing_link`: Fehlende Links zwischen Slots + - `weak_roles`: Links mit schwachen (nicht-causalen) Roles + +2. **Status zu berechnen**: + - `complete`: Slots + Links vollständig + - `near_complete`: 1-2 Links fehlen oder 1 Slot fehlt + - `partial`: Mehrere Lücken + - `weak`: Nur structural/temporal Roles, keine causal Roles + +3. **Actions anzubieten**: + - `insert_edge_forward`: Edge einfügen (von Slot A nach Slot B) + - `link_existing`: Bestehenden Knoten verlinken + - `create_note_via_interview`: Neue Note für Slot erstellen + +--- + +## 10. Konfigurationsdateien + +### `chain_templates.yaml` + +Definiert Template-Strukturen: +- Slots mit erlaubten Note-Types +- Links mit erlaubten Edge-Roles +- Profile (discovery, decisioning) + +### `chain_roles.yaml` + +Mappt Edge-Typen auf Roles: +- `causal`: Direkte Kausalität +- `influences`: Indirekte Einflussnahme +- `enables_constraints`: Ermöglicht/Einschränkt +- `structural`: Strukturelle Beziehung +- `temporal`: Zeitliche Beziehung + +### `edge_vocabulary.md` + +Definiert Canonical Edge-Typen und Aliase: +- Canonical: `resulted_in` +- Aliase: `führt_zu`, `resultiert_in`, etc. + +### `graph_schema.md` + +Definiert Note-Type-Kompatibilität: +- Welche Edge-Typen sind typisch für `insight → decision`? +- Welche Edge-Typen sind verboten? + +--- + +## Zusammenfassung + +Das System identifiziert Chains durch: + +1. **Graph-Indexierung**: Erfassung aller Edges im lokalen Subgraph +2. **Candidate-Sammlung**: Identifikation potenzieller Knoten +3. **Backtracking-Matching**: Optimale Slot-Zuordnung +4. **Link-Validierung**: Prüfung vorhandener Edges +5. **Scoring**: Bewertung der Match-Qualität + +Das Ergebnis sind **Template Matches** mit: +- Slot-Zuordnungen (welche Knoten in welchen Slots) +- Link-Status (welche Links erfüllt/fehlend) +- Confidence (confirmed/plausible/weak) +- Role Evidence (welche Edges welche Roles haben) + +Diese Matches werden dann im **Chain Workbench** verwendet, um konkrete Todos und Actions zu generieren. diff --git a/docs/03_Entwicklerhandbuch.md b/docs/03_Entwicklerhandbuch.md index 6a532b0..4b7fbc1 100644 --- a/docs/03_Entwicklerhandbuch.md +++ b/docs/03_Entwicklerhandbuch.md @@ -151,9 +151,9 @@ npm run lint ### Development Workflow 1. **Code ändern** in `src/` -2. **Watch Mode** läuft (`npm run dev`) +2. **Watch Mode** läuft (`npm.cmd run dev`) 3. **Automatisches Rebuild** bei Dateiänderungen -4. **Deploy lokal** (`npm run deploy:local` oder `npm run build:deploy`) +4. **Deploy lokal** (`npm.cmd run deploy:local` oder `npm.cmd run build:deploy`) 5. **Obsidian Plugin reload** (disable/enable) 6. **Testen** diff --git a/docs/08_Testing_Chain_Workbench.md b/docs/08_Testing_Chain_Workbench.md new file mode 100644 index 0000000..80faa59 --- /dev/null +++ b/docs/08_Testing_Chain_Workbench.md @@ -0,0 +1,309 @@ +# Testing Chain Workbench + Vault Triage (0.5.x) + +Diese Anleitung beschreibt, wie du die neuen Chain Workbench und Vault Triage Features in Obsidian testen kannst. + +## Voraussetzungen + +1. **Node.js** installiert (LTS Version empfohlen) +2. **Obsidian** installiert +3. **Vault** mit Mindnet-konfigurierten Dateien: + - `Dictionary/chain_templates.yaml` + - `Dictionary/chain_roles.yaml` + - `Dictionary/edge_vocabulary.md` + - `Dictionary/analysis_policies.yaml` (optional) + - `Dictionary/interview_config.yaml` (für Interview-Features) + +## Build & Installation + +### 1. Dependencies installieren + +```bash +cd c:\Dev\cursor\mindnet_obsidian +npm install +``` + +### 2. Plugin bauen + +```bash +npm run build +``` + +Dies erstellt `main.js` im Projekt-Root. + +### 3. Plugin in Obsidian installieren + +**Option A: Lokales Deployment (empfohlen für Entwicklung)** + +```powershell +npm run deploy:local +``` + +Dies kopiert automatisch `main.js`, `manifest.json` und `styles.css` (falls vorhanden) in dein Test-Vault. + +**Option B: Manuell** + +1. Öffne dein Obsidian Vault +2. Gehe zu `.obsidian/plugins/` +3. Erstelle Ordner `mindnet-causal-assistant` (falls nicht vorhanden) +4. Kopiere folgende Dateien: + - `main.js` (aus Projekt-Root) + - `manifest.json` (aus Projekt-Root) + - `styles.css` (falls vorhanden) + +### 4. Plugin aktivieren + +1. Öffne Obsidian Settings +2. Gehe zu **Community plugins** +3. Aktiviere **Mindnet Causal Assistant** +4. Stelle sicher, dass die Dictionary-Pfade korrekt konfiguriert sind: + - Edge Vocabulary Path: `Dictionary/edge_vocabulary.md` + - Chain Templates Path: `Dictionary/chain_templates.yaml` + - Chain Roles Path: `Dictionary/chain_roles.yaml` + - Interview Config Path: `Dictionary/interview_config.yaml` + +## Testing Chain Workbench + +### Schritt 1: Test-Vault vorbereiten + +Erstelle oder öffne eine Note mit: +- Mindestens einer Section (H2-Überschrift) +- Einigen Edges (z.B. in Semantic Mapping Block) +- Optional: Candidate Edges in "## Kandidaten" Zone + +**Beispiel-Note:** + +```markdown +--- +id: test_note_001 +type: concept +--- + +# Test Note + +## Kontext + +Dies ist eine Test-Section mit einigen Links. + +> [!abstract]- 🕸️ Semantic Mapping +>> [!edge] caused_by +>> [[Target Note 1]] + +## Kandidaten + +> [!abstract] +>> [!edge] impacts +>> [[Target Note 2]] +``` + +### Schritt 2: Chain Workbench öffnen + +1. Öffne die Test-Note im Editor +2. Setze den Cursor in eine Section (z.B. "Kontext") +3. Öffne Command Palette (`Ctrl+P` / `Cmd+P`) +4. Suche nach: **"Mindnet: Chain Workbench (Current Section)"** +5. Klicke auf den Command + +### Schritt 3: Workbench UI verwenden + +**Erwartetes Verhalten:** + +- Modal öffnet sich mit: + - Links: Liste aller Template Matches + - Rechts: Details des ausgewählten Matches +- Filter nach Status verfügbar +- Suche nach Template-Name funktioniert + +**Zu testen:** + +1. **Match-Liste**: + - Alle Matches werden angezeigt (nicht nur Top-1) + - Status-Icons sind korrekt (✓, ~, ○, ⚠) + - Sortierung: near_complete zuerst + +2. **Match-Details**: + - Slots-Info korrekt angezeigt + - Links-Info korrekt angezeigt + - Todos werden aufgelistet + +3. **Todo-Actions**: + - Klicke auf "Insert Edge" für `missing_link` Todo + - Wähle Zone (Section / Note-Verbindungen / Kandidaten) + - Wähle Edge-Typ + - Prüfe: Edge wurde eingefügt + - Prüfe: Workbench aktualisiert sich nach Apply + +4. **Candidate Promotion**: + - Wenn Candidate Edge vorhanden, sollte `candidate_cleanup` Todo erscheinen + - Klicke auf "Promote" + - Prüfe: Candidate wurde zu explizitem Edge befördert + - Prüfe: Candidate wurde aus Kandidaten-Zone entfernt (wenn `keepOriginal=false`) + +## Testing Interview-Orchestrierung + +### Schritt 1: Missing Slot Todo erstellen + +1. Öffne Chain Workbench für eine Section mit fehlendem Slot +2. Wähle einen Match mit `missing_slot` Todo + +### Schritt 2: Create Note via Interview + +1. Klicke auf "Create Note" Button +2. **Erwartetes Verhalten**: + - Profile Selection Modal öffnet sich + - Nur erlaubte Profile werden angezeigt (gefiltert nach `allowedNodeTypes`) + - User wählt Profile, Titel, Ordner +3. **Nach Auswahl**: + - Note wird erstellt mit Frontmatter + - Zonen werden erstellt (je nach Settings) + - Interview Wizard startet automatisch +4. **Nach Interview-Abschluss**: + - Soft-Validation prüft requiredEdges + - Notice zeigt fehlende Edges (falls vorhanden) + +**Zu testen:** + +- Profile-Filterung funktioniert +- Note wird korrekt erstellt +- Interview startet +- Soft-Validation zeigt korrekte Meldungen + +## Testing Vault Triage Scan + +### Schritt 1: Scan starten + +1. Öffne Command Palette +2. Suche nach: **"Mindnet: Scan Vault for Chain Gaps"** +3. Klicke auf den Command + +### Schritt 2: Scan durchführen + +**Erwartetes Verhalten:** + +- Modal öffnet sich +- "Start Scan" Button verfügbar +- Nach Klick: Scan läuft durch alle Dateien +- Progress wird angezeigt + +**Zu testen:** + +1. **Scan-Prozess**: + - Progress wird aktualisiert + - Aktuelle Datei wird angezeigt + - Scan kann unterbrochen werden (Cancel) + +2. **Backlog-Liste**: + - Items werden nach Scan angezeigt + - Sortierung: near_complete zuerst + - Gap-Counts sind korrekt + +3. **Filter**: + - Status-Filter funktioniert + - Gap-Typ-Filter funktioniert (missing_slot, missing_link, etc.) + - Suche funktioniert (File, Heading, Template) + +4. **Actions**: + - "Open Workbench" öffnet Datei und Workbench + - "Deprioritize" markiert Item als deprioritized + - Item verschwindet nicht, sondern wird gefiltert + +5. **Resume**: + - Schließe Modal während Scan läuft + - Öffne Scan erneut + - "Resume Scan" sollte verfügbar sein + - Scan setzt fort + +## Troubleshooting + +### Plugin lädt nicht + +- Prüfe: `main.js` existiert im Plugin-Ordner +- Prüfe: `manifest.json` ist vorhanden +- Prüfe: Console (F12) für Fehler +- Prüfe: Plugin ist in Settings aktiviert + +### Chain Workbench zeigt keine Matches + +- Prüfe: Chain Templates sind geladen (Settings → Dictionary Paths) +- Prüfe: Section hat Edges oder Links +- Prüfe: Console für Fehler +- Prüfe: Template Matching Profile ist korrekt + +### Interview startet nicht + +- Prüfe: Interview Config ist geladen +- Prüfe: Profile existiert für gewählten noteType +- Prüfe: Settings → `autoStartInterviewOnCreate` ist aktiviert + +### Vault Scan ist langsam + +- Normal für große Vaults +- Scan kann unterbrochen werden +- Progress wird gespeichert für Resume + +### Edge wird nicht eingefügt + +- Prüfe: Zone existiert oder wird erstellt +- Prüfe: Section hat Semantic Mapping Block oder wird erstellt +- Prüfe: Console für Fehler +- Prüfe: File-Berechtigungen + +## Debug-Modus + +Für detaillierte Logs: + +1. Öffne Obsidian Console (F12) +2. Aktiviere Debug-Logging in Settings: + - Settings → Mindnet Settings → Debug Logging + +**Wichtige Console-Logs:** + +- `[Chain Workbench]` - Workbench-Operationen +- `[Vault Triage Scan]` - Scan-Operationen +- `[Chain Inspector]` - Template Matching +- `[Workbench]` - Todo-Generierung + +## Beispiel-Workflow + +1. **Erstelle Test-Note** mit unvollständiger Chain: + ```markdown + ## Kontext + + Content hier. + + > [!abstract]- 🕸️ Semantic Mapping + >> [!edge] caused_by + >> [[Note A]] + ``` + +2. **Öffne Chain Workbench** für diese Section + +3. **Erwartung**: + - Match für Template (z.B. `trigger_transformation_outcome`) + - Status: `partial` oder `near_complete` + - Todos: `missing_slot` für "transformation" oder "outcome" + +4. **Teste Actions**: + - Klicke "Create Note" → Interview startet + - Oder: Klicke "Insert Edge" → Edge wird eingefügt + +5. **Nach Apply**: + - Workbench aktualisiert sich automatisch + - Match-Status verbessert sich (z.B. `partial` → `near_complete`) + +## Nächste Schritte + +Nach erfolgreichem Test: + +1. Teste mit realen Vault-Daten +2. Prüfe Performance bei großen Vaults +3. Teste Edge-Cases (leere Sections, fehlende Zonen, etc.) +4. Gib Feedback zu UX/UI + +## Bekannte Einschränkungen + +- `link_existing` ist noch Placeholder (fügt Link ein, aber Slot-Assignment erfordert Re-Run) +- Interview Quick-Insert (requiredEdges) ist vorbereitet, aber noch nicht vollständig integriert +- `insert_edge_inverse` noch nicht implementiert +- `change_edge_type` noch nicht implementiert + +Diese Features werden in späteren Iterationen vervollständigt. diff --git a/docs/09_Workbench_Analysis_Basis.md b/docs/09_Workbench_Analysis_Basis.md new file mode 100644 index 0000000..68d0e64 --- /dev/null +++ b/docs/09_Workbench_Analysis_Basis.md @@ -0,0 +1,278 @@ +# Chain Workbench - Analyse-Basis und Erweiterungen + +> **Version:** 0.5.x +> **Stand:** 2025-01-XX +> **Zielgruppe:** Entwickler, Architekten + +--- + +## Übersicht + +Die Chain Workbench (0.5.x) basiert **hauptsächlich** auf den Analysen aus Chain Inspector (0.4.x), erweitert diese aber um zusätzliche Heuristiken und granularere Todo-Generierung. + +--- + +## Basis: Chain Inspector 0.4.x Analysen + +### Template Matching (0.4.x) + +Die Workbench nutzt direkt die **Template Matches** aus Chain Inspector: + +- **`TemplateMatch` Interface:** + - `templateName`: Name des Templates + - `score`: Match-Score (+10 pro Link, +2 pro Slot, -5 pro fehlendem required Link) + - `slotAssignments`: Map von Slot-ID → Node-Referenz + - `missingSlots`: Array von fehlenden Slot-IDs + - `satisfiedLinks`: Anzahl erfüllter Links + - `requiredLinks`: Anzahl erforderlicher Links + - `roleEvidence`: Array von Edge-Role-Evidence + - `slotsComplete`: Boolean + - `linksComplete`: Boolean + - `confidence`: "confirmed" | "plausible" | "weak" + +**Quelle:** `src/analysis/chainInspector.ts` → `inspectChains()` → `matchTemplates()` + +### Findings aus 0.4.x (aktuell NICHT verwendet, aber sollten integriert werden) + +**Status:** Die Findings aus Chain Inspector werden aktuell **nicht** in der Workbench verwendet, obwohl sie aufwendig getestet wurden und wertvolle Informationen liefern. + +**Findings aus 0.4.x:** +- `missing_edges` - Section hat Content aber keine Edges +- `one_sided_connectivity` - Nur incoming oder nur outgoing edges +- `only_candidates` - Nur Candidate-Edges, keine expliziten +- `dangling_target` - Target-Datei existiert nicht +- `dangling_target_heading` - Target-Heading existiert nicht +- `no_causal_roles` - Section hat Edges aber keine kausalen Rollen +- `missing_slot_` - Template-basiert: Slot fehlt (aggregiert) +- `missing_link_constraints` - Template-basiert: Links fehlen (aggregiert) +- `weak_chain_roles` - Template-basiert: Nur non-causal Rollen (aggregiert) + +**Warum aktuell nicht verwendet?** +- Die Workbench fokussiert sich aktuell nur auf **Template-basierte Todos** +- Findings sind teilweise **aggregiert** (z.B. "missing_slot_trigger" für alle Matches) +- Workbench benötigt **granulare Todos** pro Match (z.B. "missing_slot_trigger" für jeden Match einzeln) +- Workbench benötigt **konkrete Actions** (z.B. `insert_edge_forward`, `create_note_via_interview`) + +**Sollten integriert werden:** +Die Findings könnten als **zusätzliche Todos** integriert werden: +- `dangling_target` → Todo mit Action `create_missing_note` oder `retarget_link` +- `dangling_target_heading` → Todo mit Action `create_missing_heading` oder `retarget_to_existing_heading` +- `only_candidates` → Todo mit Action `promote_all_candidates` oder `create_explicit_edges` +- `missing_edges` → Todo mit Action `add_edges_to_section` +- `no_causal_roles` → Todo ähnlich wie `weak_roles`, aber section-weit (nicht template-basiert) +- `one_sided_connectivity` → Informatives Todo (keine Action, nur Hinweis) + +**Vorteil der Integration:** +- Nutzung der **getesteten Heuristiken** aus 0.4.x +- **Vollständigere Analyse** (nicht nur Template-basiert) +- **Konsistenz** zwischen Chain Inspector und Workbench + +--- + +## Neue Analysen in 0.5.x + +### 1. Status-Berechnung (`statusCalculator.ts`) + +**Neu in 0.5.x:** Berechnet `MatchStatus` basierend auf Slot/Link-Erfüllung: + +- **`complete`**: `slotsFilled == slotsTotal && linksSatisfied == linksRequired` +- **`near_complete`**: + - `slotsFilled == slotsTotal && (linksRequired - linksSatisfied) in {1,2}` + - ODER `(slotsTotal - slotsFilled) == 1 && linksSatisfied == linksRequired` +- **`weak`**: Match vorhanden, aber nur structural/temporal Rollen (keine causal) +- **`partial`**: Sonstige Fälle mit mindestens einer Lücke + +**Basis:** TemplateMatch aus 0.4.x, aber **neue Heuristik** für Status-Kategorisierung. + +### 2. Granulare Todo-Generierung (`todoGenerator.ts`) + +**Neu in 0.5.x:** Generiert konkrete Todos pro Match: + +#### `missing_slot` Todo +- **Basis:** `match.missingSlots` aus TemplateMatch +- **Erweiterung:** + - Pro Slot ein Todo (nicht aggregiert) + - Enthält `allowedNodeTypes` für Interview-Filterung + - Enthält Actions: `["link_existing", "create_note_via_interview"]` + +#### `missing_link` Todo +- **Basis:** Template-Links aus `template.links` + `match.slotAssignments` +- **Erweiterung:** + - Prüft, ob beide Slots gefüllt sind, aber Link fehlt + - Enthält `fromNodeRef` und `toNodeRef` (konkrete Referenzen) + - Enthält `suggestedEdgeTypes` aus `chain_roles.yaml` + - Enthält Actions: `["insert_edge_forward", "insert_edge_inverse", "choose_target_anchor"]` + +#### `weak_roles` Todo +- **Basis:** `match.roleEvidence` aus TemplateMatch +- **Erweiterung:** + - Prüft, ob alle Rollen structural/temporal sind (keine causal) + - Enthält Liste von `edges` mit `currentRole` und `suggestedRoles` + - Enthält Actions: `["change_edge_type"]` + +#### `candidate_cleanup` Todo (NEU) +- **Basis:** Candidate-Edges aus `graphIndex` + `missing_link` Todos +- **Vollständig neu:** + - Findet Candidate-Edges, die zu `missing_link` Requirements passen + - **Relation-Equality-Check:** Prüft, ob bereits ein confirmed Edge für dieselbe Relation existiert + - Wenn ja → kein Todo (verhindert Duplikate) + - Wenn nein → Todo mit `promote_candidate` Action + +**Relation-Equality-Logik:** +```typescript +// Prüft: +// 1. Direction match (from → to) +// 2. Node-Refs match (file + heading) +// 3. Canonical Edge-Type match (via edge_vocabulary.md) +``` + +### 3. Effective Required Links Resolution + +**Neu in 0.5.x:** Resolved `required_links` Flag mit Priorität: + +1. `template.matching?.required_links` (höchste Priorität) +2. `profile.required_links` +3. `defaults.matching?.required_links` +4. Fallback: `false` + +**Basis:** TemplateMatch nutzt bereits `requiredLinks`, aber Workbench benötigt explizite Resolution für UI-Darstellung. + +--- + +## Vergleich: Findings vs. Todos + +| Aspekt | Chain Inspector 0.4.x (Findings) | Chain Workbench 0.5.x (Todos) | +|--------|----------------------------------|-------------------------------| +| **Granularität** | Aggregiert (pro Template-Typ) | Granular (pro Match, pro Slot/Link) | +| **Format** | `Finding` Interface (code, severity, message) | `WorkbenchTodoUnion` Interface (type, actions, refs) | +| **Actions** | Keine (nur Informativ) | Konkrete Actions (`insert_edge_forward`, etc.) | +| **Basis** | Template Matches + Gap-Heuristiken | Template Matches + zusätzliche Analysen | +| **Verwendung** | Console-Output, Fix Findings Command | Workbench UI, interaktive Bearbeitung | + +--- + +## Abhängigkeiten + +### Direkte Abhängigkeiten (0.4.x) + +1. **`TemplateMatch`** aus `chainInspector.ts` + - Wird direkt verwendet für Status-Berechnung und Todo-Generierung + +2. **`ChainTemplatesConfig`** aus `dictionary/types.ts` + - Wird verwendet für Slot/Link-Definitionen und `allowed_node_types` + +3. **`ChainRolesConfig`** aus `dictionary/types.ts` + - Wird verwendet für `suggestedEdgeTypes` in `missing_link` Todos + +4. **`EdgeVocabulary`** aus `vocab/types.ts` + - Wird verwendet für Canonical-Type-Resolution (Relation-Equality) + +5. **`IndexedEdge[]`** aus `graphIndex.ts` + - Wird verwendet für Candidate/Confirmed-Edge-Trennung + +### Indirekte Abhängigkeiten + +1. **Template Matching Algorithmus** (0.4.x) + - Backtracking-Algorithmus für Slot-Assignment + - Scoring-Mechanismus (+10 pro Link, +2 pro Slot, -5 pro fehlendem required Link) + +2. **Edge-Role-Mapping** (0.4.x) + - `chain_roles.yaml` → Edge-Types → Roles + - Wird verwendet für `weak_roles` Detection + +--- + +## Neue Heuristiken (nicht in 0.4.x) + +### 1. Relation-Equality-Check + +**Zweck:** Verhindert Duplikate bei Candidate-Cleanup. + +**Logik:** +- Prüft, ob bereits ein confirmed Edge für dieselbe Relation existiert +- Relation = (fromNodeRef, toNodeRef, canonicalEdgeType) +- Wenn ja → kein `candidate_cleanup` Todo + +**Implementierung:** `todoGenerator.ts` → `checkRelationExists()` + +### 2. Status-Kategorisierung + +**Zweck:** Priorisierung von Matches in Workbench UI. + +**Logik:** +- `complete` → höchste Priorität (grün) +- `near_complete` → hohe Priorität (gelb) +- `partial` → mittlere Priorität (orange) +- `weak` → niedrige Priorität (rot) + +**Implementierung:** `statusCalculator.ts` → `calculateMatchStatus()` + +### 3. Effective Required Links Resolution + +**Zweck:** Korrekte Darstellung von Link-Requirements in UI. + +**Logik:** +- Resolved `required_links` Flag mit Priorität (template > profile > defaults) +- Beeinflusst, ob `missing_link` Todos generiert werden + +**Implementierung:** `statusCalculator.ts` → `getEffectiveRequiredLinks()` + +--- + +## Zusammenfassung + +### Basierend auf 0.4.x + +✅ **Template Matches** - Direkt verwendet +✅ **Slot-Assignments** - Direkt verwendet +✅ **Missing Slots** - Direkt verwendet +✅ **Role Evidence** - Direkt verwendet +✅ **Edge-Role-Mapping** - Direkt verwendet +✅ **Canonical Edge Types** - Direkt verwendet + +### Neu in 0.5.x + +🆕 **Status-Berechnung** - Neue Heuristik +🆕 **Granulare Todos** - Pro Match, pro Slot/Link +🆕 **Candidate-Cleanup** - Relation-Equality-Check +🆕 **Effective Required Links** - Prioritäts-Resolution +🆕 **Action-System** - Konkrete Actions pro Todo + +### Nicht verwendet aus 0.4.x (sollten integriert werden) + +⚠️ **Findings** - Werden aktuell nicht verwendet, sollten aber integriert werden +⚠️ **Gap-Heuristiken** - Werden aktuell nicht verwendet, sollten aber integriert werden +⚠️ **Dangling Target Checks** - Werden aktuell nicht verwendet, sollten aber integriert werden + +**Empfehlung:** Die Findings sollten als **zusätzliche Todos** in die Workbench integriert werden, um die getesteten Heuristiken zu nutzen und eine vollständigere Analyse zu bieten. + +--- + +## Fazit + +Die Chain Workbench (0.5.x) basiert **hauptsächlich** auf den Template Matches aus Chain Inspector (0.4.x), erweitert diese aber um: + +1. **Granulare Todo-Generierung** (pro Match, pro Slot/Link) +2. **Status-Kategorisierung** (complete, near_complete, partial, weak) +3. **Candidate-Cleanup-Heuristik** (Relation-Equality-Check) +4. **Action-System** (konkrete Actions pro Todo) + +**Aktueller Stand:** +Die Workbench verwendet aktuell **nur** die Template Matches aus 0.4.x, nicht die Findings. Dies ermöglicht eine **interaktive Bearbeitung** mit konkreten Actions. + +**Empfohlene Erweiterung:** +Die Findings aus 0.4.x sollten als **zusätzliche Todos** integriert werden, um: +- Die **getesteten Heuristiken** zu nutzen +- Eine **vollständigere Analyse** zu bieten (nicht nur Template-basiert) +- **Konsistenz** zwischen Chain Inspector und Workbench zu gewährleisten + +**Nächste Schritte:** +1. Findings aus `report.findings` in `buildWorkbenchModel` einlesen +2. Findings zu Todos konvertieren (mit entsprechenden Actions) +3. Findings-Todos zu den Template-basierten Todos hinzufügen +4. UI erweitern, um beide Todo-Typen anzuzeigen + +--- + +**Letzte Aktualisierung:** 2025-01-XX +**Version:** 0.5.x diff --git a/docs/10_Workbench_Findings_Integration.md b/docs/10_Workbench_Findings_Integration.md new file mode 100644 index 0000000..eed8f6b --- /dev/null +++ b/docs/10_Workbench_Findings_Integration.md @@ -0,0 +1,342 @@ +# Chain Workbench - Findings Integration (Vorschlag) + +> **Status:** Vorschlag für zukünftige Implementierung +> **Stand:** 2025-01-XX +> **Zielgruppe:** Entwickler + +--- + +## Problemstellung + +Die Chain Workbench (0.5.x) verwendet aktuell **nur** Template Matches aus Chain Inspector (0.4.x), nicht aber die **Findings** und **Gap-Heuristiken**, die aufwendig getestet wurden. + +**Verlorene Informationen:** +- `dangling_target` - Target-Datei existiert nicht +- `dangling_target_heading` - Target-Heading existiert nicht +- `only_candidates` - Nur Candidate-Edges, keine expliziten +- `missing_edges` - Section hat Content aber keine Edges +- `no_causal_roles` - Section hat Edges aber keine kausalen Rollen +- `one_sided_connectivity` - Nur incoming oder nur outgoing edges + +--- + +## Lösungsvorschlag + +### 1. Findings zu Todos konvertieren + +Erweitere `todoGenerator.ts` um eine Funktion `generateFindingsTodos()`: + +```typescript +/** + * Generate todos from Chain Inspector findings. + */ +export function generateFindingsTodos( + findings: Finding[], + allEdges: IndexedEdge[], + context: { file: string; heading: string | null } +): WorkbenchTodoUnion[] { + const todos: WorkbenchTodoUnion[] = []; + + for (const finding of findings) { + switch (finding.code) { + case "dangling_target": + // Find edge with dangling target + const danglingEdge = allEdges.find( + (e) => e.target.file === finding.evidence?.file + ); + if (danglingEdge) { + todos.push({ + type: "dangling_target", + id: `dangling_target_${finding.evidence?.file}`, + description: finding.message, + priority: finding.severity === "error" ? "high" : "medium", + targetFile: danglingEdge.target.file, + targetHeading: danglingEdge.target.heading, + sourceEdge: { + file: "sectionHeading" in danglingEdge.source + ? danglingEdge.source.file + : danglingEdge.source.file, + heading: "sectionHeading" in danglingEdge.source + ? danglingEdge.source.sectionHeading + : null, + }, + actions: ["create_missing_note", "retarget_link"], + }); + } + break; + + case "dangling_target_heading": + // Similar to dangling_target, but for headings + todos.push({ + type: "dangling_target_heading", + id: `dangling_target_heading_${finding.evidence?.file}_${finding.evidence?.sectionHeading}`, + description: finding.message, + priority: finding.severity === "warn" ? "medium" : "low", + targetFile: finding.evidence?.file || "", + targetHeading: finding.evidence?.sectionHeading || null, + actions: ["create_missing_heading", "retarget_to_existing_heading"], + }); + break; + + case "only_candidates": + // Find all candidate edges in section + const candidateEdges = allEdges.filter( + (e) => e.scope === "candidate" && + ("sectionHeading" in e.source + ? e.source.sectionHeading === context.heading + : false) + ); + if (candidateEdges.length > 0) { + todos.push({ + type: "only_candidates", + id: `only_candidates_${context.file}_${context.heading}`, + description: finding.message, + priority: "medium", + candidateEdges: candidateEdges.map((e) => ({ + rawEdgeType: e.rawEdgeType, + from: "sectionHeading" in e.source + ? { file: e.source.file, heading: e.source.sectionHeading } + : { file: e.source.file, heading: null }, + to: e.target, + })), + actions: ["promote_all_candidates", "create_explicit_edges"], + }); + } + break; + + case "missing_edges": + todos.push({ + type: "missing_edges", + id: `missing_edges_${context.file}_${context.heading}`, + description: finding.message, + priority: "medium", + section: { + file: context.file, + heading: context.heading, + }, + actions: ["add_edges_to_section"], + }); + break; + + case "no_causal_roles": + // Find edges without causal roles + const nonCausalEdges = allEdges.filter( + (e) => e.scope === "section" && + ("sectionHeading" in e.source + ? e.source.sectionHeading === context.heading + : false) + ); + todos.push({ + type: "no_causal_roles", + id: `no_causal_roles_${context.file}_${context.heading}`, + description: finding.message, + priority: "medium", + edges: nonCausalEdges.map((e) => ({ + rawEdgeType: e.rawEdgeType, + from: "sectionHeading" in e.source + ? { file: e.source.file, heading: e.source.sectionHeading } + : { file: e.source.file, heading: null }, + to: e.target, + currentRole: null, // Would need to resolve via chain_roles + suggestedRoles: ["causal", "influences", "enables_constraints"], + })), + actions: ["change_edge_type"], + }); + break; + + case "one_sided_connectivity": + // Informational only + todos.push({ + type: "one_sided_connectivity", + id: `one_sided_connectivity_${context.file}_${context.heading}`, + description: finding.message, + priority: "low", + section: { + file: context.file, + heading: context.heading, + }, + actions: [], // No actions, informational only + }); + break; + } + } + + return todos; +} +``` + +### 2. Erweitere Todo-Types + +Füge neue Todo-Types zu `types.ts` hinzu: + +```typescript +export type TodoType = + | "missing_slot" + | "missing_link" + | "weak_roles" + | "candidate_cleanup" + | "create_candidates_zone" + | "create_note_links_zone" + | "dangling_target" // NEU + | "dangling_target_heading" // NEU + | "only_candidates" // NEU + | "missing_edges" // NEU + | "no_causal_roles" // NEU + | "one_sided_connectivity"; // NEU + +export interface DanglingTargetTodo extends WorkbenchTodo { + type: "dangling_target"; + targetFile: string; + targetHeading: string | null; + sourceEdge: { + file: string; + heading: string | null; + }; + actions: Array<"create_missing_note" | "retarget_link">; +} + +export interface DanglingTargetHeadingTodo extends WorkbenchTodo { + type: "dangling_target_heading"; + targetFile: string; + targetHeading: string | null; + actions: Array<"create_missing_heading" | "retarget_to_existing_heading">; +} + +export interface OnlyCandidatesTodo extends WorkbenchTodo { + type: "only_candidates"; + candidateEdges: Array<{ + rawEdgeType: string; + from: { file: string; heading: string | null }; + to: { file: string; heading: string | null }; + }>; + actions: Array<"promote_all_candidates" | "create_explicit_edges">; +} + +export interface MissingEdgesTodo extends WorkbenchTodo { + type: "missing_edges"; + section: { + file: string; + heading: string | null; + }; + actions: Array<"add_edges_to_section">; +} + +export interface NoCausalRolesTodo extends WorkbenchTodo { + type: "no_causal_roles"; + edges: Array<{ + rawEdgeType: string; + from: { file: string; heading: string | null }; + to: { file: string; heading: string | null }; + currentRole: string | null; + suggestedRoles: string[]; + }>; + actions: Array<"change_edge_type">; +} + +export interface OneSidedConnectivityTodo extends WorkbenchTodo { + type: "one_sided_connectivity"; + section: { + file: string; + heading: string | null; + }; + actions: []; // Informational only +} +``` + +### 3. Integriere Findings in Workbench Builder + +Erweitere `workbenchBuilder.ts`: + +```typescript +export async function buildWorkbenchModel( + app: App, + report: ChainInspectorReport, + chainTemplates: ChainTemplatesConfig | null, + chainRoles: ChainRolesConfig | null, + edgeVocabulary: EdgeVocabulary | null, + allEdges: IndexedEdge[] +): Promise { + const matches: WorkbenchMatch[] = []; + const globalTodos: WorkbenchTodoUnion[] = []; // NEU: Global todos from findings + + // ... existing template match processing ... + + // NEU: Generate todos from findings + if (report.findings && report.findings.length > 0) { + const findingsTodos = generateFindingsTodos( + report.findings, + allEdges, + report.context + ); + globalTodos.push(...findingsTodos); + } + + return { + context: report.context, + matches, + globalTodos, // NEU: Add global todos + timestamp: Date.now(), + }; +} +``` + +### 4. Erweitere WorkbenchModel Interface + +```typescript +export interface WorkbenchModel { + context: { + file: string; + heading: string | null; + zoneKind: string; + }; + matches: WorkbenchMatch[]; + globalTodos?: WorkbenchTodoUnion[]; // NEU: Todos from findings (not template-based) + timestamp: number; +} +``` + +### 5. UI-Integration + +Erweitere `ChainWorkbenchModal.ts`: + +```typescript +// Show global todos (from findings) separately +if (model.globalTodos && model.globalTodos.length > 0) { + // Render "Section-Level Issues" section + // Show findings-based todos before template matches +} +``` + +--- + +## Vorteile + +1. **Nutzt getestete Heuristiken** - Findings aus 0.4.x werden verwendet +2. **Vollständigere Analyse** - Nicht nur Template-basiert, sondern auch Section-Level +3. **Konsistenz** - Chain Inspector und Workbench zeigen dieselben Probleme +4. **Bessere UX** - User sieht alle Probleme auf einen Blick + +--- + +## Implementierungsreihenfolge + +1. **Phase 1:** Todo-Types erweitern (`types.ts`) +2. **Phase 2:** `generateFindingsTodos()` implementieren (`todoGenerator.ts`) +3. **Phase 3:** `buildWorkbenchModel()` erweitern (`workbenchBuilder.ts`) +4. **Phase 4:** UI erweitern (`ChainWorkbenchModal.ts`) +5. **Phase 5:** Actions implementieren (z.B. `create_missing_note`, `retarget_link`) + +--- + +## Offene Fragen + +1. **Priorität:** Sollen Findings-Todos höhere Priorität haben als Template-Todos? +2. **Duplikate:** Wie vermeiden wir Duplikate zwischen Findings-Todos und Template-Todos? + - Beispiel: `dangling_target` könnte auch als `missing_link` Todo erscheinen +3. **Filterung:** Sollen Findings-Todos gefiltert werden können (z.B. nur Errors)? +4. **Actions:** Welche Actions sind für welche Findings sinnvoll? + +--- + +**Letzte Aktualisierung:** 2025-01-XX +**Status:** Vorschlag für zukünftige Implementierung diff --git a/docs/readme.md b/docs/readme.md index 9d4e702..e6e69b4 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -106,6 +106,7 @@ Diese Dokumentation deckt alle Aspekte des **Mindnet Causal Assistant** Plugins ### Konzepte & Details - [02_causal_chain_retrieving.md](./02_causal_chain_retrieving.md) - Kausale Ketten-Retrieval +- [02_concepts/03_chain_identification_and_matching.md](./02_concepts/03_chain_identification_and_matching.md) - **Chain-Identifikation und Template Matching** - Vollständige Erklärung des Prozesses, wie Chains identifiziert und gefüllt werden - [DANGLING_TARGET_CASES.md](./DANGLING_TARGET_CASES.md) - Dangling Target Findings - [06_Konfigurationsdateien_Referenz.md](./06_Konfigurationsdateien_Referenz.md) - Vollständige Referenz aller Config-Dateien - [07_Event_Handler_Commands.md](./07_Event_Handler_Commands.md) - Event Handler & Commands Referenz @@ -114,6 +115,15 @@ Diese Dokumentation deckt alle Aspekte des **Mindnet Causal Assistant** Plugins - [00_Dokumentations_Index.md](./00_Dokumentations_Index.md) - Vollständige Übersicht aller Dokumentationen +### Testing + +- [08_Testing_Chain_Workbench.md](./08_Testing_Chain_Workbench.md) - Anleitung zum Testen der Chain Workbench und Vault Triage Features (0.5.x) + +### Architektur & Analyse-Basis + +- [09_Workbench_Analysis_Basis.md](./09_Workbench_Analysis_Basis.md) - Analyse-Basis der Chain Workbench (0.5.x) und Abhängigkeiten zu Chain Inspector (0.4.x) +- [10_Workbench_Findings_Integration.md](./10_Workbench_Findings_Integration.md) - Vorschlag zur Integration der Findings aus 0.4.x in die Workbench + ### Legacy-Dokumentation - [readme.md](./readme.md) - MVP 1.0 Quickstart diff --git a/src/commands/chainWorkbenchCommand.ts b/src/commands/chainWorkbenchCommand.ts new file mode 100644 index 0000000..fa53472 --- /dev/null +++ b/src/commands/chainWorkbenchCommand.ts @@ -0,0 +1,112 @@ +/** + * Command: Chain Workbench (Current Section) + */ + +import { Notice } from "obsidian"; +import type { App, Editor } from "obsidian"; +import { resolveSectionContext } from "../analysis/sectionContext"; +import { inspectChains } from "../analysis/chainInspector"; +import type { ChainRolesConfig, ChainTemplatesConfig, DictionaryLoadResult } from "../dictionary/types"; +import type { MindnetSettings } from "../settings"; +import { buildWorkbenchModel } from "../workbench/workbenchBuilder"; +import { buildNoteIndex } from "../analysis/graphIndex"; +import { TFile } from "obsidian"; +import { VocabularyLoader } from "../vocab/VocabularyLoader"; +import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary"; +import { Vocabulary } from "../vocab/Vocabulary"; + +/** + * Execute chain workbench command. + */ +export async function executeChainWorkbench( + app: App, + editor: Editor, + filePath: string, + chainRoles: ChainRolesConfig | null, + chainTemplates: ChainTemplatesConfig | null, + templatesLoadResult: DictionaryLoadResult | undefined, + settings: MindnetSettings, + pluginInstance: any +): Promise { + try { + // Resolve section context + const context = resolveSectionContext(editor, filePath); + + // Build inspector options + const inspectorOptions = { + includeNoteLinks: true, + includeCandidates: settings.chainInspectorIncludeCandidates, + maxDepth: 3, + direction: "both" as const, + maxTemplateMatches: undefined, // No limit - we want ALL matches + }; + + // Load vocabulary + const vocabText = await VocabularyLoader.loadText(app, settings.edgeVocabularyPath); + const edgeVocabulary = parseEdgeVocabulary(vocabText); + const vocabulary = new Vocabulary(edgeVocabulary); + + // Prepare templates source info + let templatesSourceInfo: { path: string; status: string; loadedAt: number | null; templateCount: number } | undefined; + if (templatesLoadResult) { + templatesSourceInfo = { + path: templatesLoadResult.resolvedPath, + status: templatesLoadResult.status, + loadedAt: templatesLoadResult.loadedAt, + templateCount: chainTemplates?.templates?.length || 0, + }; + } + + // Inspect chains + const report = await inspectChains( + app, + context, + inspectorOptions, + chainRoles, + settings.edgeVocabularyPath, + chainTemplates, + templatesSourceInfo, + settings.templateMatchingProfile + ); + + // Build all edges index for todo generation + // Get edges from current note (includes candidates) + const activeFile = app.vault.getAbstractFileByPath(filePath); + if (!activeFile || !(activeFile instanceof TFile)) { + throw new Error("Active file not found"); + } + + const { edges: allEdges } = await buildNoteIndex(app, activeFile); + + // Also add edges from neighbors (for relation equality checking) + // These are already in the report but we need them in IndexedEdge format + // For now, we'll use the current note edges and check candidates from there + + // Build workbench model + const workbenchModel = await buildWorkbenchModel( + app, + report, + chainTemplates, + chainRoles, + edgeVocabulary, + allEdges + ); + + // Open workbench UI + const { ChainWorkbenchModal } = await import("../ui/ChainWorkbenchModal"); + const modal = new ChainWorkbenchModal( + app, + workbenchModel, + settings, + chainRoles, + chainTemplates, + vocabulary, + pluginInstance + ); + modal.open(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[Chain Workbench] Error:", e); + new Notice(`Failed to open chain workbench: ${msg}`); + } +} diff --git a/src/commands/vaultTriageScanCommand.ts b/src/commands/vaultTriageScanCommand.ts new file mode 100644 index 0000000..15177a1 --- /dev/null +++ b/src/commands/vaultTriageScanCommand.ts @@ -0,0 +1,50 @@ +/** + * Command: Scan Vault for Chain Gaps + */ + +import { Notice } from "obsidian"; +import type { App } from "obsidian"; +import type { ChainRolesConfig, ChainTemplatesConfig, DictionaryLoadResult } from "../dictionary/types"; +import type { MindnetSettings } from "../settings"; +import { scanVaultForChainGaps, saveScanState, loadScanState, type ScanState, type ScanItem } from "../workbench/vaultTriageScan"; +import { VocabularyLoader } from "../vocab/VocabularyLoader"; +import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary"; + +/** + * Execute vault triage scan command. + */ +export async function executeVaultTriageScan( + app: App, + chainRoles: ChainRolesConfig | null, + chainTemplates: ChainTemplatesConfig | null, + templatesLoadResult: DictionaryLoadResult | undefined, + settings: MindnetSettings, + pluginInstance: any +): Promise { + try { + // Load vocabulary + const vocabText = await VocabularyLoader.loadText(app, settings.edgeVocabularyPath); + const edgeVocabulary = parseEdgeVocabulary(vocabText); + + // Check for existing scan state + const existingState = await loadScanState(app, pluginInstance); + const shouldResume = existingState && !existingState.completed; + + // Open scan UI + const { VaultTriageScanModal } = await import("../ui/VaultTriageScanModal"); + const modal = new VaultTriageScanModal( + app, + chainRoles, + chainTemplates, + edgeVocabulary, + settings, + pluginInstance, + existingState + ); + modal.open(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[Vault Triage Scan] Error:", e); + new Notice(`Failed to start vault triage scan: ${msg}`); + } +} diff --git a/src/main.ts b/src/main.ts index a40d47d..f24b9a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -587,6 +587,77 @@ export default class MindnetCausalAssistantPlugin extends Plugin { }, }); + this.addCommand({ + id: "mindnet-chain-workbench", + name: "Mindnet: Chain Workbench (Current Section)", + editorCallback: async (editor) => { + try { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + new Notice("No active file"); + return; + } + + if (activeFile.extension !== "md") { + new Notice("Active file is not a markdown file"); + return; + } + + // Ensure chain roles and templates are loaded + await this.ensureChainRolesLoaded(); + const chainRoles = this.chainRoles.data; + await this.ensureChainTemplatesLoaded(); + const chainTemplates = this.chainTemplates.data; + const templatesLoadResult = this.chainTemplates.result; + + const { executeChainWorkbench } = await import("./commands/chainWorkbenchCommand"); + await executeChainWorkbench( + this.app, + editor, + activeFile.path, + chainRoles, + chainTemplates, + templatesLoadResult || undefined, + this.settings, + this + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + new Notice(`Failed to open chain workbench: ${msg}`); + console.error(e); + } + }, + }); + + this.addCommand({ + id: "mindnet-scan-chain-gaps", + name: "Mindnet: Scan Vault for Chain Gaps", + callback: async () => { + try { + // Ensure chain roles and templates are loaded + await this.ensureChainRolesLoaded(); + const chainRoles = this.chainRoles.data; + await this.ensureChainTemplatesLoaded(); + const chainTemplates = this.chainTemplates.data; + const templatesLoadResult = this.chainTemplates.result; + + const { executeVaultTriageScan } = await import("./commands/vaultTriageScanCommand"); + await executeVaultTriageScan( + this.app, + chainRoles, + chainTemplates, + templatesLoadResult || undefined, + this.settings, + this + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + new Notice(`Failed to start vault triage scan: ${msg}`); + console.error(e); + } + }, + }); + this.addCommand({ id: "mindnet-build-semantic-mappings", name: "Mindnet: Build semantic mapping blocks (by section)", diff --git a/src/tests/workbench/statusCalculator.test.ts b/src/tests/workbench/statusCalculator.test.ts new file mode 100644 index 0000000..530f902 --- /dev/null +++ b/src/tests/workbench/statusCalculator.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for status calculator. + */ + +import { describe, it, expect } from "vitest"; +import { + calculateMatchStatus, + calculateSlotsStats, + hasWeakRoles, + getEffectiveRequiredLinks, +} from "../../workbench/statusCalculator"; +import type { TemplateMatch } from "../../analysis/chainInspector"; + +describe("calculateMatchStatus", () => { + it("should return 'complete' when all slots filled and all links satisfied", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 20, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "concept" }, + }, + missingSlots: [], + satisfiedLinks: 2, + requiredLinks: 2, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + }; + + const status = calculateMatchStatus(match, false); + expect(status).toBe("complete"); + }); + + it("should return 'near_complete' when slots complete but 1 link missing", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 18, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "concept" }, + }, + missingSlots: [], + satisfiedLinks: 1, + requiredLinks: 2, + slotsComplete: true, + linksComplete: false, + confidence: "plausible", + }; + + const status = calculateMatchStatus(match, false); + expect(status).toBe("near_complete"); + }); + + it("should return 'near_complete' when 1 slot missing but all links satisfied", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 18, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + }, + missingSlots: ["slot2"], + satisfiedLinks: 1, + requiredLinks: 1, + slotsComplete: false, + linksComplete: true, + confidence: "plausible", + }; + + const status = calculateMatchStatus(match, false); + expect(status).toBe("near_complete"); + }); + + it("should return 'weak' when hasWeakRoles is true", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + }, + missingSlots: ["slot2"], + satisfiedLinks: 0, + requiredLinks: 1, + slotsComplete: false, + linksComplete: false, + confidence: "weak", + }; + + const status = calculateMatchStatus(match, true); + expect(status).toBe("weak"); + }); + + it("should return 'partial' for other cases", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 8, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + }, + missingSlots: ["slot2", "slot3"], + satisfiedLinks: 0, + requiredLinks: 2, + slotsComplete: false, + linksComplete: false, + confidence: "weak", + }; + + const status = calculateMatchStatus(match, false); + expect(status).toBe("partial"); + }); +}); + +describe("calculateSlotsStats", () => { + it("should calculate filled and total slots correctly", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "concept" }, + }, + missingSlots: ["slot3"], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: false, + linksComplete: true, + confidence: "plausible", + }; + + const stats = calculateSlotsStats(match); + expect(stats.filled).toBe(2); + expect(stats.total).toBe(3); + }); + + it("should handle empty slots", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 0, + slotAssignments: {}, + missingSlots: ["slot1", "slot2"], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: false, + linksComplete: true, + confidence: "weak", + }; + + const stats = calculateSlotsStats(match); + expect(stats.filled).toBe(0); + expect(stats.total).toBe(2); + }); +}); + +describe("hasWeakRoles", () => { + it("should return false when no roleEvidence", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + }; + + expect(hasWeakRoles(match)).toBe(false); + }); + + it("should return false when causal roles present", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 1, + requiredLinks: 1, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + roleEvidence: [ + { + from: "file1:", + to: "file2:", + edgeRole: "causal", + rawEdgeType: "caused_by", + }, + ], + }; + + expect(hasWeakRoles(match)).toBe(false); + }); + + it("should return true when only structural/temporal roles", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 1, + requiredLinks: 1, + slotsComplete: true, + linksComplete: true, + confidence: "weak", + roleEvidence: [ + { + from: "file1:", + to: "file2:", + edgeRole: "structural", + rawEdgeType: "part_of", + }, + { + from: "file2:", + to: "file3:", + edgeRole: "temporal", + rawEdgeType: "followed_by", + }, + ], + }; + + expect(hasWeakRoles(match)).toBe(true); + }); + + it("should return false when mixed roles (causal + structural)", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 2, + requiredLinks: 2, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + roleEvidence: [ + { + from: "file1:", + to: "file2:", + edgeRole: "causal", + rawEdgeType: "caused_by", + }, + { + from: "file2:", + to: "file3:", + edgeRole: "structural", + rawEdgeType: "part_of", + }, + ], + }; + + expect(hasWeakRoles(match)).toBe(false); + }); +}); + +describe("getEffectiveRequiredLinks", () => { + it("should prioritize template.matching.required_links", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + }; + + const result = getEffectiveRequiredLinks(match, false, true, false); + expect(result).toBe(true); + }); + + it("should fall back to profile.required_links when template not set", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + }; + + const result = getEffectiveRequiredLinks(match, true, undefined, false); + expect(result).toBe(true); + }); + + it("should fall back to defaults.matching.required_links when profile not set", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + }; + + const result = getEffectiveRequiredLinks(match, undefined, undefined, true); + expect(result).toBe(true); + }); + + it("should return false as fallback when nothing set", () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: {}, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + }; + + const result = getEffectiveRequiredLinks(match, undefined, undefined, undefined); + expect(result).toBe(false); + }); +}); diff --git a/src/tests/workbench/todoGenerator.test.ts b/src/tests/workbench/todoGenerator.test.ts new file mode 100644 index 0000000..85f6a8d --- /dev/null +++ b/src/tests/workbench/todoGenerator.test.ts @@ -0,0 +1,401 @@ +/** + * Tests for todo generator. + */ + +import { describe, it, expect } from "vitest"; +import { generateTodos } from "../../workbench/todoGenerator"; +import type { TemplateMatch } from "../../analysis/chainInspector"; +import type { ChainTemplate } from "../../dictionary/types"; +import type { ChainRolesConfig } from "../../dictionary/types"; +import type { IndexedEdge } from "../../analysis/graphIndex"; +import type { EdgeVocabulary } from "../../vocab/types"; + +// Mock App +const mockApp = { + vault: {}, +} as any; + +// Mock Edge Vocabulary +const mockEdgeVocabulary: EdgeVocabulary = { + byCanonical: new Map([ + ["caused_by", { canonical: "caused_by", aliases: [], description: "" }], + ["impacts", { canonical: "impacts", aliases: [], description: "" }], + ]), + aliasToCanonical: new Map(), +}; + +// Mock Chain Roles +const mockChainRoles: ChainRolesConfig = { + roles: { + causal: { + edge_types: ["caused_by", "resulted_in"], + }, + influences: { + edge_types: ["impacts", "impacted_by"], + }, + }, +}; + +describe("generateTodos", () => { + it("should generate missing_slot todo for missing slots", async () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + }, + missingSlots: ["slot2"], + satisfiedLinks: 0, + requiredLinks: 0, + slotsComplete: false, + linksComplete: true, + confidence: "plausible", + }; + + const template: ChainTemplate = { + name: "test_template", + slots: [ + { id: "slot1", allowed_node_types: ["concept"] }, + { id: "slot2", allowed_node_types: ["decision"] }, + ], + links: [], + }; + + const todos = await generateTodos( + mockApp, + match, + template, + null, + null, + [], + [], + [], + { file: "test.md", heading: null } + ); + + expect(todos.length).toBeGreaterThan(0); + const missingSlotTodo = todos.find((t) => t.type === "missing_slot"); + expect(missingSlotTodo).toBeDefined(); + expect(missingSlotTodo?.type).toBe("missing_slot"); + if (missingSlotTodo && missingSlotTodo.type === "missing_slot") { + expect(missingSlotTodo.slotId).toBe("slot2"); + expect(missingSlotTodo.allowedNodeTypes).toEqual(["decision"]); + } + }); + + it("should generate missing_link todo when slots filled but link missing", async () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "decision" }, + }, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 1, + slotsComplete: true, + linksComplete: false, + confidence: "plausible", + }; + + const template: ChainTemplate = { + name: "test_template", + slots: [ + { id: "slot1", allowed_node_types: ["concept"] }, + { id: "slot2", allowed_node_types: ["decision"] }, + ], + links: [ + { + from: "slot1", + to: "slot2", + allowed_edge_roles: ["causal"], + }, + ], + }; + + const todos = await generateTodos( + mockApp, + match, + template, + mockChainRoles, + mockEdgeVocabulary, + [], + [], + [], + { file: "test.md", heading: null } + ); + + expect(todos.length).toBeGreaterThan(0); + const missingLinkTodo = todos.find((t) => t.type === "missing_link"); + expect(missingLinkTodo).toBeDefined(); + expect(missingLinkTodo?.type).toBe("missing_link"); + if (missingLinkTodo && missingLinkTodo.type === "missing_link") { + expect(missingLinkTodo.fromSlotId).toBe("slot1"); + expect(missingLinkTodo.toSlotId).toBe("slot2"); + expect(missingLinkTodo.suggestedEdgeTypes).toContain("caused_by"); + expect(missingLinkTodo.suggestedEdgeTypes).toContain("resulted_in"); + } + }); + + it("should not generate missing_link todo when link exists", async () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "decision" }, + }, + missingSlots: [], + satisfiedLinks: 1, + requiredLinks: 1, + slotsComplete: true, + linksComplete: true, + confidence: "confirmed", + roleEvidence: [ + { + // roleEvidence uses nodeKey format: "file:heading" + from: "file1.md:", + to: "file2.md:", + edgeRole: "causal", + rawEdgeType: "caused_by", + }, + ], + }; + + const template: ChainTemplate = { + name: "test_template", + slots: [ + { id: "slot1", allowed_node_types: ["concept"] }, + { id: "slot2", allowed_node_types: ["decision"] }, + ], + links: [ + { + from: "slot1", + to: "slot2", + allowed_edge_roles: ["causal"], + }, + ], + }; + + const todos = await generateTodos( + mockApp, + match, + template, + mockChainRoles, + mockEdgeVocabulary, + [], + [], + [], + { file: "test.md", heading: null } + ); + + const missingLinkTodo = todos.find((t) => t.type === "missing_link"); + expect(missingLinkTodo).toBeUndefined(); + }); + + it("should generate weak_roles todo when only structural/temporal roles", async () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "concept" }, + }, + missingSlots: [], + satisfiedLinks: 2, + requiredLinks: 2, + slotsComplete: true, + linksComplete: true, + confidence: "weak", + roleEvidence: [ + { + from: "file1:", + to: "file2:", + edgeRole: "structural", + rawEdgeType: "part_of", + }, + { + from: "file2:", + to: "file3:", + edgeRole: "temporal", + rawEdgeType: "followed_by", + }, + ], + }; + + const template: ChainTemplate = { + name: "test_template", + slots: [ + { id: "slot1", allowed_node_types: ["concept"] }, + { id: "slot2", allowed_node_types: ["concept"] }, + ], + links: [], + }; + + const allEdges: IndexedEdge[] = [ + { + rawEdgeType: "part_of", + source: { file: "file1.md", sectionHeading: null }, + target: { file: "file2.md", heading: null }, + scope: "section", + evidence: { file: "test.md", sectionHeading: null }, + }, + { + rawEdgeType: "followed_by", + source: { file: "file2.md", sectionHeading: null }, + target: { file: "file3.md", heading: null }, + scope: "section", + evidence: { file: "test.md", sectionHeading: null }, + }, + ]; + + const todos = await generateTodos( + mockApp, + match, + template, + mockChainRoles, + mockEdgeVocabulary, + allEdges, + [], + [], + { file: "test.md", heading: null } + ); + + const weakRolesTodo = todos.find((t) => t.type === "weak_roles"); + expect(weakRolesTodo).toBeDefined(); + expect(weakRolesTodo?.type).toBe("weak_roles"); + if (weakRolesTodo && weakRolesTodo.type === "weak_roles") { + expect(weakRolesTodo.edges.length).toBe(2); + } + }); + + it("should generate candidate_cleanup todo when candidate matches missing link", async () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "decision" }, + }, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 1, + slotsComplete: true, + linksComplete: false, + confidence: "plausible", + }; + + const template: ChainTemplate = { + name: "test_template", + slots: [ + { id: "slot1", allowed_node_types: ["concept"] }, + { id: "slot2", allowed_node_types: ["decision"] }, + ], + links: [ + { + from: "slot1", + to: "slot2", + allowed_edge_roles: ["causal"], + }, + ], + }; + + const candidateEdges: IndexedEdge[] = [ + { + rawEdgeType: "caused_by", + source: { file: "file1.md", sectionHeading: null }, + target: { file: "file2.md", heading: null }, + scope: "candidate", + evidence: { file: "test.md", sectionHeading: "Kandidaten", lineRange: { start: 5, end: 7 } }, + }, + ]; + + const todos = await generateTodos( + mockApp, + match, + template, + mockChainRoles, + mockEdgeVocabulary, + [], + candidateEdges, + [], + { file: "test.md", heading: null } + ); + + const candidateCleanupTodo = todos.find((t) => t.type === "candidate_cleanup"); + expect(candidateCleanupTodo).toBeDefined(); + expect(candidateCleanupTodo?.type).toBe("candidate_cleanup"); + if (candidateCleanupTodo && candidateCleanupTodo.type === "candidate_cleanup") { + expect(candidateCleanupTodo.candidateEdge.rawEdgeType).toBe("caused_by"); + expect(candidateCleanupTodo.neededFor.matchName).toBe("test_template"); + } + }); + + it("should not generate candidate_cleanup when confirmed edge exists", async () => { + const match: TemplateMatch = { + templateName: "test_template", + score: 10, + slotAssignments: { + slot1: { nodeKey: "file1:", file: "file1.md", heading: null, noteType: "concept" }, + slot2: { nodeKey: "file2:", file: "file2.md", heading: null, noteType: "decision" }, + }, + missingSlots: [], + satisfiedLinks: 0, + requiredLinks: 1, + slotsComplete: true, + linksComplete: false, + confidence: "plausible", + }; + + const template: ChainTemplate = { + name: "test_template", + slots: [ + { id: "slot1", allowed_node_types: ["concept"] }, + { id: "slot2", allowed_node_types: ["decision"] }, + ], + links: [ + { + from: "slot1", + to: "slot2", + allowed_edge_roles: ["causal"], + }, + ], + }; + + const candidateEdges: IndexedEdge[] = [ + { + rawEdgeType: "caused_by", + source: { file: "file1.md", sectionHeading: null }, + target: { file: "file2.md", heading: null }, + scope: "candidate", + evidence: { file: "test.md", sectionHeading: "Kandidaten" }, + }, + ]; + + const confirmedEdges: IndexedEdge[] = [ + { + rawEdgeType: "caused_by", + source: { file: "file1.md", sectionHeading: null }, + target: { file: "file2.md", heading: null }, + scope: "section", + evidence: { file: "test.md", sectionHeading: "My Section" }, + }, + ]; + + const todos = await generateTodos( + mockApp, + match, + template, + mockChainRoles, + mockEdgeVocabulary, + [], + candidateEdges, + confirmedEdges, + { file: "test.md", heading: null } + ); + + const candidateCleanupTodo = todos.find((t) => t.type === "candidate_cleanup"); + expect(candidateCleanupTodo).toBeUndefined(); + }); +}); diff --git a/src/tests/workbench/zoneDetector.test.ts b/src/tests/workbench/zoneDetector.test.ts new file mode 100644 index 0000000..d0e9644 --- /dev/null +++ b/src/tests/workbench/zoneDetector.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for zone detector. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { detectZone, findSection } from "../../workbench/zoneDetector"; +import type { App, TFile } from "obsidian"; + +// Mock App +function createMockApp(content: string): { app: App; file: TFile } { + const file = { + path: "test.md", + name: "test.md", + extension: "md", + basename: "test", + stat: { size: 0, ctime: 0, mtime: 0 }, + vault: {} as any, + parent: null, + } as TFile; + + const app = { + vault: { + read: async (f: TFile) => { + if (f.path === file.path) { + return content; + } + throw new Error("File not found"); + }, + }, + } as unknown as App; + + return { app, file }; +} + +describe("detectZone", () => { + it("should detect existing candidates zone", async () => { + const content = `# Test Note + +Some content. + +## Kandidaten + +> [!abstract] +> [!edge] caused_by +> [[Target Note]] + +## Other Section + +More content. +`; + + const { app, file } = createMockApp(content); + const zone = await detectZone(app, file, "candidates"); + + expect(zone.exists).toBe(true); + expect(zone.heading).toBe("## Kandidaten"); + expect(zone.startLine).toBeGreaterThanOrEqual(0); + expect(zone.endLine).toBeGreaterThan(zone.startLine); + expect(zone.content).toContain("Kandidaten"); + expect(zone.content).toContain("caused_by"); + }); + + it("should detect existing note_links zone", async () => { + const content = `# Test Note + +Some content. + +## Note-Verbindungen + +> [!abstract] +> [!edge] impacts +> [[Other Note]] + +## Other Section + +More content. +`; + + const { app, file } = createMockApp(content); + const zone = await detectZone(app, file, "note_links"); + + expect(zone.exists).toBe(true); + expect(zone.heading).toBe("## Note-Verbindungen"); + expect(zone.content).toContain("Note-Verbindungen"); + }); + + it("should return exists=false when zone not found", async () => { + const content = `# Test Note + +Some content. + +## Other Section + +More content. +`; + + const { app, file } = createMockApp(content); + const zone = await detectZone(app, file, "candidates"); + + expect(zone.exists).toBe(false); + expect(zone.startLine).toBe(-1); + }); + + it("should handle zone at end of file", async () => { + const content = `# Test Note + +Some content. + +## Kandidaten + +> [!abstract] +> [!edge] caused_by +> [[Target Note]] +`; + + const { app, file } = createMockApp(content); + const zone = await detectZone(app, file, "candidates"); + + expect(zone.exists).toBe(true); + expect(zone.endLine).toBeGreaterThan(zone.startLine); + }); +}); + +describe("findSection", () => { + it("should find section by heading", async () => { + const content = `# Test Note + +Some content. + +## My Section + +Section content here. + +## Other Section + +More content. +`; + + const { app, file } = createMockApp(content); + const section = await findSection(app, file, "My Section"); + + expect(section).not.toBeNull(); + expect(section?.exists).toBe(true); + expect(section?.heading).toBe("My Section"); + expect(section?.content).toContain("Section content here"); + }); + + it("should find root section (null heading)", async () => { + const content = `Root content before first heading. + +## First Heading + +Content after heading. +`; + + const { app, file } = createMockApp(content); + const section = await findSection(app, file, null); + + expect(section).not.toBeNull(); + expect(section?.exists).toBe(true); + expect(section?.heading).toBe(""); + expect(section?.content).toContain("Root content"); + expect(section?.content).not.toContain("First Heading"); + }); + + it("should return null when section not found", async () => { + const content = `# Test Note + +Some content. + +## Other Section + +More content. +`; + + const { app, file } = createMockApp(content); + const section = await findSection(app, file, "Non-existent Section"); + + expect(section).toBeNull(); + }); + + it("should handle section boundaries correctly", async () => { + const content = `# Level 1 + +Content 1. + +## Level 2 + +Content 2. + +### Level 3 + +Content 3. + +## Another Level 2 + +Content 4. +`; + + const { app, file } = createMockApp(content); + const section = await findSection(app, file, "Level 2"); + + expect(section).not.toBeNull(); + expect(section?.content).toContain("Content 2"); + // Level 3 is a sub-section of Level 2, so it should be included + expect(section?.content).toContain("Content 3"); + expect(section?.content).not.toContain("Another Level 2"); + expect(section?.content).not.toContain("Content 4"); + }); +}); diff --git a/src/ui/ChainWorkbenchModal.ts b/src/ui/ChainWorkbenchModal.ts new file mode 100644 index 0000000..27d26c1 --- /dev/null +++ b/src/ui/ChainWorkbenchModal.ts @@ -0,0 +1,678 @@ +/** + * Chain Workbench Modal - UI for chain workbench. + */ + +import { Modal, Setting, Notice } from "obsidian"; +import type { App } from "obsidian"; +import type { WorkbenchModel, WorkbenchMatch, WorkbenchTodoUnion } from "../workbench/types"; +import type { MindnetSettings } from "../settings"; +import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; +import type { Vocabulary } from "../vocab/Vocabulary"; + +export class ChainWorkbenchModal extends Modal { + private model: WorkbenchModel; + private settings: MindnetSettings; + private chainRoles: ChainRolesConfig | null; + private chainTemplates: ChainTemplatesConfig | null; + private vocabulary: Vocabulary; + private pluginInstance: any; + + private selectedMatch: WorkbenchMatch | null = null; + private filterStatus: string | null = null; + private searchQuery: string = ""; + + constructor( + app: App, + model: WorkbenchModel, + settings: MindnetSettings, + chainRoles: ChainRolesConfig | null, + chainTemplates: ChainTemplatesConfig | null, + vocabulary: Vocabulary, + pluginInstance: any + ) { + super(app); + this.model = model; + this.settings = settings; + this.chainRoles = chainRoles; + this.chainTemplates = chainTemplates; + this.vocabulary = vocabulary; + this.pluginInstance = pluginInstance; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + // Header + contentEl.createEl("h2", { text: "Chain Workbench" }); + contentEl.createEl("p", { + text: `Context: ${this.model.context.file}${this.model.context.heading ? `#${this.model.context.heading}` : ""}`, + }); + + // Filters + const filterContainer = contentEl.createDiv({ cls: "workbench-filters" }); + filterContainer.createEl("label", { text: "Filter by Status:" }); + const statusSelect = filterContainer.createEl("select"); + statusSelect.createEl("option", { text: "All", value: "" }); + statusSelect.createEl("option", { text: "Complete", value: "complete" }); + statusSelect.createEl("option", { text: "Near Complete", value: "near_complete" }); + statusSelect.createEl("option", { text: "Partial", value: "partial" }); + statusSelect.createEl("option", { text: "Weak", value: "weak" }); + statusSelect.addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement; + this.filterStatus = target.value || null; + this.render(); + }); + + filterContainer.createEl("label", { text: "Search:" }); + const searchInput = filterContainer.createEl("input", { type: "text", placeholder: "Template name..." }); + searchInput.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + this.searchQuery = target.value.toLowerCase(); + this.render(); + }); + + // Main container: list + details + const mainContainer = contentEl.createDiv({ cls: "workbench-main" }); + + // Left: Match list + const listContainer = mainContainer.createDiv({ cls: "workbench-list" }); + listContainer.createEl("h3", { text: "Template Matches" }); + + // Right: Details + const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" }); + detailsContainer.createEl("h3", { text: "Details" }); + + // Global todos section (if any) + if (this.model.globalTodos && this.model.globalTodos.length > 0) { + const globalTodosSection = contentEl.createDiv({ cls: "workbench-global-todos" }); + globalTodosSection.createEl("h3", { text: "Section-Level Issues" }); + globalTodosSection.createEl("p", { + text: `Found ${this.model.globalTodos.length} issue(s) from Chain Inspector findings`, + }); + } + + this.render(); + } + + private render(): void { + const { contentEl } = this; + + // Filter matches + let filteredMatches = this.model.matches; + if (this.filterStatus) { + filteredMatches = filteredMatches.filter((m) => m.status === this.filterStatus); + } + if (this.searchQuery) { + filteredMatches = filteredMatches.filter((m) => + m.templateName.toLowerCase().includes(this.searchQuery) + ); + } + + // Sort: near_complete first (default), then by score descending + filteredMatches.sort((a, b) => { + if (a.status === "near_complete" && b.status !== "near_complete") return -1; + if (a.status !== "near_complete" && b.status === "near_complete") return 1; + return b.score - a.score; + }); + + // Render list + const listContainer = contentEl.querySelector(".workbench-list"); + if (listContainer) { + listContainer.empty(); + listContainer.createEl("h3", { text: `Template Matches (${filteredMatches.length})` }); + + for (const match of filteredMatches) { + const matchEl = listContainer.createDiv({ cls: "workbench-match-item" }); + if (this.selectedMatch === match) { + matchEl.addClass("selected"); + } + + const statusIcon = this.getStatusIcon(match.status); + matchEl.createEl("div", { + cls: "match-header", + text: `${statusIcon} ${match.templateName}`, + }); + + matchEl.createEl("div", { + cls: "match-stats", + text: `Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks} | Score: ${match.score}`, + }); + + matchEl.createEl("div", { + cls: "match-todos-count", + text: `${match.todos.length} todo(s)`, + }); + + matchEl.addEventListener("click", () => { + this.selectedMatch = match; + this.renderDetails(); + }); + } + } + + // Render details + this.renderDetails(); + + // Render global todos + this.renderGlobalTodos(); + } + + private renderGlobalTodos(): void { + const globalTodosSection = this.contentEl.querySelector(".workbench-global-todos"); + if (!globalTodosSection || !this.model.globalTodos || this.model.globalTodos.length === 0) { + return; + } + + // Clear and re-render + const existingList = globalTodosSection.querySelector(".global-todos-list"); + if (existingList) { + existingList.remove(); + } + + const todosList = globalTodosSection.createDiv({ cls: "global-todos-list" }); + + for (const todo of this.model.globalTodos) { + const todoEl = todosList.createDiv({ cls: "global-todo-item" }); + todoEl.createEl("div", { cls: "todo-type", text: todo.type }); + todoEl.createEl("div", { cls: "todo-description", text: todo.description }); + todoEl.createEl("div", { cls: "todo-priority", text: `Priority: ${todo.priority}` }); + + // Render actions + const actionsContainer = todoEl.createDiv({ cls: "todo-actions" }); + if ( + todo.type === "dangling_target" || + todo.type === "dangling_target_heading" || + todo.type === "only_candidates" || + todo.type === "missing_edges" || + todo.type === "no_causal_roles" + ) { + const actions = (todo as any).actions || []; + for (const action of actions) { + const actionBtn = actionsContainer.createEl("button", { + text: this.getActionLabel(action), + }); + actionBtn.addEventListener("click", () => { + this.handleGlobalTodoAction(todo, action); + }); + } + } else if (todo.type === "one_sided_connectivity") { + // Informational only - no actions + actionsContainer.createEl("span", { text: "Informational only", cls: "info-text" }); + } + } + } + + private renderDetails(): void { + const detailsContainer = this.contentEl.querySelector(".workbench-details"); + if (!detailsContainer) return; + + detailsContainer.empty(); + detailsContainer.createEl("h3", { text: "Details" }); + + if (!this.selectedMatch) { + detailsContainer.createEl("p", { text: "Select a match to view details" }); + return; + } + + const match = this.selectedMatch; + + // Match info + detailsContainer.createEl("h4", { text: match.templateName }); + detailsContainer.createEl("p", { + text: `Status: ${match.status} | Score: ${match.score} | Confidence: ${match.confidence}`, + }); + + // Slots + detailsContainer.createEl("h5", { text: "Slots" }); + const slotsList = detailsContainer.createEl("ul"); + for (const [slotId, assignment] of Object.entries(match.slotAssignments)) { + if (assignment) { + const nodeStr = assignment.heading + ? `${assignment.file}#${assignment.heading}` + : assignment.file; + slotsList.createEl("li", { text: `${slotId}: ${nodeStr} [${assignment.noteType}]` }); + } + } + if (match.missingSlots.length > 0) { + slotsList.createEl("li", { + text: `Missing: ${match.missingSlots.join(", ")}`, + cls: "missing", + }); + } + + // Links + detailsContainer.createEl("h5", { text: "Links" }); + detailsContainer.createEl("p", { + text: `${match.satisfiedLinks}/${match.requiredLinks} satisfied (${match.linksComplete ? "complete" : "incomplete"})`, + }); + + // Todos + detailsContainer.createEl("h5", { text: `Todos (${match.todos.length})` }); + const todosList = detailsContainer.createEl("div", { cls: "todos-list" }); + + for (const todo of match.todos) { + const todoEl = todosList.createDiv({ cls: `todo-item todo-${todo.type}` }); + todoEl.createEl("div", { cls: "todo-description", text: todo.description }); + todoEl.createEl("div", { cls: "todo-priority", text: `Priority: ${todo.priority}` }); + + // Render actions + const actionsContainer = todoEl.createDiv({ cls: "todo-actions" }); + if ( + todo.type === "missing_slot" || + todo.type === "missing_link" || + todo.type === "weak_roles" || + todo.type === "candidate_cleanup" + ) { + const actions = (todo as any).actions || []; + for (const action of actions) { + const actionBtn = actionsContainer.createEl("button", { + text: this.getActionLabel(action), + }); + actionBtn.addEventListener("click", () => { + this.handleTodoAction(todo as WorkbenchTodoUnion, action, match); + }); + } + } + } + } + + private getStatusIcon(status: string): string { + switch (status) { + case "complete": + return "✓"; + case "near_complete": + return "~"; + case "partial": + return "○"; + case "weak": + return "⚠"; + default: + return "?"; + } + } + + private getActionLabel(action: string): string { + const labels: Record = { + link_existing: "Link Existing", + create_note_via_interview: "Create Note", + insert_edge_forward: "Insert Edge", + insert_edge_inverse: "Insert Inverse", + choose_target_anchor: "Choose Anchor", + change_edge_type: "Change Type", + promote_candidate: "Promote", + resolve_candidate: "Resolve", + create_missing_note: "Create Missing Note", + retarget_link: "Retarget Link", + create_missing_heading: "Create Missing Heading", + retarget_to_existing_heading: "Retarget to Existing Heading", + promote_all_candidates: "Promote All Candidates", + create_explicit_edges: "Create Explicit Edges", + add_edges_to_section: "Add Edges to Section", + }; + return labels[action] || action; + } + + private async handleTodoAction( + todo: WorkbenchTodoUnion, + action: string, + match: WorkbenchMatch + ): Promise { + console.log("[Chain Workbench] Action:", action, "for todo:", todo.type); + + try { + const activeFile = this.app.workspace.getActiveFile(); + const activeEditor = this.app.workspace.activeEditor?.editor; + + if (!activeFile || !activeEditor) { + new Notice("No active file or editor"); + return; + } + + // Import action handlers + const { insertEdgeForward, promoteCandidate } = await import("../workbench/writerActions"); + const graphSchema = await this.pluginInstance.ensureGraphSchemaLoaded(); + + switch (action) { + case "insert_edge_forward": + if (todo.type === "missing_link") { + console.log("[Chain Workbench] Starting insert_edge_forward for missing_link todo"); + // Let user choose zone + console.log("[Chain Workbench] Opening zone chooser..."); + const zoneChoice = await this.chooseZone(activeFile); + console.log("[Chain Workbench] Zone choice:", zoneChoice); + if (zoneChoice === null) { + console.log("[Chain Workbench] User cancelled zone selection"); + return; // User cancelled + } + + // Get edge vocabulary + console.log("[Chain Workbench] Loading edge vocabulary..."); + const { VocabularyLoader } = await import("../vocab/VocabularyLoader"); + const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary"); + const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath); + const edgeVocabulary = parseEdgeVocabulary(vocabText); + console.log("[Chain Workbench] Edge vocabulary loaded, entries:", edgeVocabulary.byCanonical.size); + + console.log("[Chain Workbench] Calling insertEdgeForward..."); + try { + await insertEdgeForward( + this.app, + activeEditor, + activeFile, + todo, + this.vocabulary, + edgeVocabulary, + this.settings, + graphSchema, + zoneChoice + ); + console.log("[Chain Workbench] insertEdgeForward completed successfully"); + } catch (error) { + console.error("[Chain Workbench] Error in insertEdgeForward:", error); + new Notice(`Error inserting edge: ${error instanceof Error ? error.message : String(error)}`); + return; + } + + // Re-run analysis + console.log("[Chain Workbench] Refreshing workbench..."); + await this.refreshWorkbench(); + new Notice("Edge inserted successfully"); + } + break; + + case "promote_candidate": + if (todo.type === "candidate_cleanup") { + await promoteCandidate( + this.app, + activeEditor, + activeFile, + todo, + this.settings + ); + + // Re-run analysis + await this.refreshWorkbench(); + new Notice("Candidate promoted successfully"); + } + break; + + case "link_existing": + if (todo.type === "missing_slot") { + const { linkExisting } = await import("../workbench/linkExistingAction"); + await linkExisting( + this.app, + activeEditor, + activeFile, + todo, + this.model.context + ); + + // Re-run analysis + await this.refreshWorkbench(); + new Notice("Link inserted. Re-run workbench to update slot assignments."); + } + break; + + case "create_note_via_interview": + if (todo.type === "missing_slot") { + const { createNoteViaInterview } = await import("../workbench/interviewOrchestration"); + + // Get interview config from plugin instance + let interviewConfig = null; + if (this.pluginInstance.ensureInterviewConfigLoaded) { + interviewConfig = await this.pluginInstance.ensureInterviewConfigLoaded(); + } + + if (!interviewConfig) { + new Notice("Interview config not available"); + return; + } + + // Find the match for this todo + const matchForTodo = this.model.matches.find((m) => + m.todos.some((t) => t.id === todo.id) + ); + + if (!matchForTodo) { + new Notice("Could not find match for todo"); + return; + } + + // Get edge vocabulary - we need to load it from settings + const { VocabularyLoader } = await import("../vocab/VocabularyLoader"); + const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary"); + const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath); + const edgeVocabulary = parseEdgeVocabulary(vocabText); + + await createNoteViaInterview( + this.app, + todo, + matchForTodo.templateName, + matchForTodo, + interviewConfig, + this.chainTemplates, + this.chainRoles, + this.settings, + this.vocabulary, + edgeVocabulary, + this.pluginInstance + ); + + // Re-run analysis after note creation + await this.refreshWorkbench(); + new Notice("Note created via interview"); + } + break; + + default: + new Notice(`Action "${action}" not yet implemented`); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[Chain Workbench] Error handling action:", e); + new Notice(`Failed to execute action: ${msg}`); + } + } + + private async handleGlobalTodoAction(todo: WorkbenchTodoUnion, action: string): Promise { + console.log("[Chain Workbench] Global Todo Action:", action, "for todo:", todo.type); + + try { + const activeFile = this.app.workspace.getActiveFile(); + const activeEditor = this.app.workspace.activeEditor?.editor; + + if (!activeFile || !activeEditor) { + new Notice("No active file or editor"); + return; + } + + // Import action handlers + const { promoteCandidate } = await import("../workbench/writerActions"); + const { linkExisting } = await import("../workbench/linkExistingAction"); + + switch (action) { + case "create_missing_note": + if (todo.type === "dangling_target") { + const danglingTodo = todo as any; + // Use linkExisting action to create note + await linkExisting( + this.app, + activeEditor, + activeFile, + { + type: "missing_slot", + id: `dangling_target_create_${danglingTodo.targetFile}`, + description: `Create missing note: ${danglingTodo.targetFile}`, + priority: "high", + slotId: "", + allowedNodeTypes: [], + actions: ["create_note_via_interview"], + }, + this.model.context + ); + await this.refreshWorkbench(); + new Notice("Note creation initiated"); + } + break; + + case "retarget_link": + if (todo.type === "dangling_target") { + const danglingTodo = todo as any; + // Use linkExisting to retarget + await linkExisting( + this.app, + activeEditor, + activeFile, + { + type: "missing_slot", + id: `dangling_target_retarget_${danglingTodo.targetFile}`, + description: `Retarget link to existing note`, + priority: "high", + slotId: "", + allowedNodeTypes: [], + actions: ["link_existing"], + }, + this.model.context + ); + await this.refreshWorkbench(); + new Notice("Link retargeting initiated"); + } + break; + + case "promote_all_candidates": + if (todo.type === "only_candidates") { + const onlyCandidatesTodo = todo as any; + // Promote each candidate edge + for (const candidateEdge of onlyCandidatesTodo.candidateEdges || []) { + // Find the actual edge in the graph + // This is a simplified version - full implementation would need to find the edge + new Notice(`Promoting candidate edge: ${candidateEdge.rawEdgeType}`); + } + await this.refreshWorkbench(); + new Notice("Candidates promoted"); + } + break; + + case "promote_candidate": + // Similar to template-based promote_candidate + if (todo.type === "candidate_cleanup") { + await promoteCandidate( + this.app, + activeEditor, + activeFile, + todo, + this.settings + ); + await this.refreshWorkbench(); + new Notice("Candidate promoted"); + } + break; + + case "change_edge_type": + if (todo.type === "no_causal_roles" || todo.type === "weak_roles") { + // Use existing change_edge_type logic + new Notice("Change edge type - feature coming soon"); + } + break; + + default: + new Notice(`Action "${action}" not yet implemented for ${todo.type}`); + break; + } + } catch (e) { + console.error("[Chain Workbench] Error handling global todo action:", e); + new Notice(`Error: ${e instanceof Error ? e.message : String(e)}`); + } + } + + private async chooseZone(file: any): Promise<"section" | "note_links" | "candidates" | null> { + console.log("[chooseZone] Starting zone selection"); + return new Promise((resolve) => { + let resolved = false; + const { Modal, Setting } = require("obsidian"); + const modal = new Modal(this.app); + modal.titleEl.textContent = "Choose Zone"; + modal.contentEl.createEl("p", { text: "Where should the edge be inserted?" }); + + const doResolve = (value: "section" | "note_links" | "candidates" | null) => { + if (!resolved) { + resolved = true; + resolve(value); + } + }; + + new Setting(modal.contentEl) + .setName("Section-scope") + .setDesc("Insert in source section (standard)") + .addButton((btn: any) => + btn.setButtonText("Select").onClick(() => { + console.log("[chooseZone] User selected: section"); + doResolve("section"); + modal.close(); + }) + ); + + new Setting(modal.contentEl) + .setName("Note-Verbindungen") + .setDesc("Insert in Note-Verbindungen zone (note-scope)") + .addButton((btn: any) => + btn.setButtonText("Select").onClick(() => { + console.log("[chooseZone] User selected: note_links"); + doResolve("note_links"); + modal.close(); + }) + ); + + new Setting(modal.contentEl) + .setName("Kandidaten") + .setDesc("Insert in Kandidaten zone (candidate)") + .addButton((btn: any) => + btn.setButtonText("Select").onClick(() => { + console.log("[chooseZone] User selected: candidates"); + doResolve("candidates"); + modal.close(); + }) + ); + + modal.onClose = () => { + console.log("[chooseZone] Modal closed, resolved:", resolved); + if (!resolved) { + console.log("[chooseZone] Resolving with null (user cancelled)"); + doResolve(null); + } + }; + + console.log("[chooseZone] Opening modal..."); + modal.open(); + }); + } + + private async refreshWorkbench(): Promise { + // Close and reopen workbench to refresh + this.close(); + + // Re-run command + const { executeChainWorkbench } = await import("../commands/chainWorkbenchCommand"); + const activeFile = this.app.workspace.getActiveFile(); + const activeEditor = this.app.workspace.activeEditor?.editor; + + if (activeFile && activeEditor) { + await executeChainWorkbench( + this.app, + activeEditor, + activeFile.path, + this.chainRoles, + this.chainTemplates, + undefined, + this.settings, + this.pluginInstance + ); + } + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/ui/EdgeTypeChooserModal.ts b/src/ui/EdgeTypeChooserModal.ts index 6535b27..268e59b 100644 --- a/src/ui/EdgeTypeChooserModal.ts +++ b/src/ui/EdgeTypeChooserModal.ts @@ -20,19 +20,22 @@ export class EdgeTypeChooserModal extends Modal { private targetType: string | null; private graphSchema: GraphSchema | null; private selectedCanonical: string | null = null; + private suggestedTypes: string[]; // Suggested edge types from chain_roles constructor( app: any, vocabulary: EdgeVocabulary, sourceType: string | null, targetType: string | null, - graphSchema: GraphSchema | null = null + graphSchema: GraphSchema | null = null, + suggestedTypes: string[] = [] ) { super(app); this.vocabulary = vocabulary; this.sourceType = sourceType; this.targetType = targetType; this.graphSchema = graphSchema; + this.suggestedTypes = suggestedTypes; } onOpen(): void { @@ -57,20 +60,21 @@ export class EdgeTypeChooserModal extends Modal { totalEdgeTypes: allEdgeTypes.length, categories: Array.from(grouped.keys()), categorySizes: Array.from(grouped.entries()).map(([cat, types]) => ({ category: cat, count: types.length })), + suggestedTypes: this.suggestedTypes.length, }); - // Recommended section (if any) - if (suggestions.typical.length > 0) { - contentEl.createEl("h3", { text: "⭐ Recommended (schema)" }); - const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" }); + // PRIORITY 1: Suggested types from chain_roles (if any) + if (this.suggestedTypes.length > 0) { + contentEl.createEl("h3", { text: "🎯 Suggested (from chain)" }); + const suggestedContainer = contentEl.createEl("div", { cls: "edge-type-list" }); - for (const canonical of suggestions.typical) { + for (const canonical of this.suggestedTypes) { const entry = this.vocabulary.byCanonical.get(canonical); if (!entry) continue; // Display: canonical type (primary), aliases shown after selection - const btn = recommendedContainer.createEl("button", { - text: `⭐ ${canonical}`, + const btn = suggestedContainer.createEl("button", { + text: `🎯 ${canonical}`, cls: "mod-cta", }); @@ -86,6 +90,36 @@ export class EdgeTypeChooserModal extends Modal { } } + // PRIORITY 2: Recommended section (if any, and not already in suggested) + if (suggestions.typical.length > 0) { + const notInSuggested = suggestions.typical.filter((t) => !this.suggestedTypes.includes(t)); + if (notInSuggested.length > 0) { + contentEl.createEl("h3", { text: "⭐ Recommended (schema)" }); + const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" }); + + for (const canonical of notInSuggested) { + const entry = this.vocabulary.byCanonical.get(canonical); + if (!entry) continue; + + // Display: canonical type (primary), aliases shown after selection + const btn = recommendedContainer.createEl("button", { + text: `⭐ ${canonical}`, + cls: "mod-cta", + }); + + // Add description as tooltip/hover text + if (entry.description) { + btn.title = entry.description; + btn.setAttribute("data-description", entry.description); + } + + btn.onclick = () => { + this.selectEdgeType(canonical, entry.aliases); + }; + } + } + } + // All categories if (grouped.size > 1 || (grouped.size === 1 && !grouped.has("All"))) { contentEl.createEl("h3", { text: "📂 All categories" }); @@ -105,11 +139,14 @@ export class EdgeTypeChooserModal extends Modal { const container = contentEl.createEl("div", { cls: "edge-type-list" }); for (const { canonical, aliases, displayName, description } of types) { + // Skip if already shown in suggested or recommended sections + if (this.suggestedTypes.includes(canonical) || suggestions.typical.includes(canonical)) { + continue; + } + const isProhibited = suggestions.prohibited.includes(canonical); - const isTypical = suggestions.typical.includes(canonical); let prefix = "→"; - if (isTypical) prefix = "⭐"; if (isProhibited) prefix = "🚫"; // Display: canonical type (primary), aliases shown after selection diff --git a/src/ui/VaultTriageScanModal.ts b/src/ui/VaultTriageScanModal.ts new file mode 100644 index 0000000..61f99af --- /dev/null +++ b/src/ui/VaultTriageScanModal.ts @@ -0,0 +1,362 @@ +/** + * Vault Triage Scan Modal - UI for vault triage scan backlog. + */ + +import { Modal, Setting, Notice } from "obsidian"; +import type { App } from "obsidian"; +import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; +import type { MindnetSettings } from "../settings"; +import type { EdgeVocabulary } from "../vocab/types"; +import { scanVaultForChainGaps, saveScanState, type ScanState, type ScanItem } from "../workbench/vaultTriageScan"; + +export class VaultTriageScanModal extends Modal { + private chainRoles: ChainRolesConfig | null; + private chainTemplates: ChainTemplatesConfig | null; + private edgeVocabulary: EdgeVocabulary | null; + private settings: MindnetSettings; + private pluginInstance: any; + private existingState: ScanState | null; + + private items: ScanItem[] = []; + private filterStatus: string | null = null; + private filterHasMissingSlot: boolean = false; + private filterHasMissingLink: boolean = false; + private filterWeak: boolean = false; + private filterCandidateCleanup: boolean = false; + private searchQuery: string = ""; + private isScanning: boolean = false; + private shouldCancel: boolean = false; + + constructor( + app: App, + chainRoles: ChainRolesConfig | null, + chainTemplates: ChainTemplatesConfig | null, + edgeVocabulary: EdgeVocabulary | null, + settings: MindnetSettings, + pluginInstance: any, + existingState: ScanState | null + ) { + super(app); + this.chainRoles = chainRoles; + this.chainTemplates = chainTemplates; + this.edgeVocabulary = edgeVocabulary; + this.settings = settings; + this.pluginInstance = pluginInstance; + this.existingState = existingState; + + if (existingState) { + this.items = existingState.items; + } + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + // Header + contentEl.createEl("h2", { text: "Vault Triage Scan" }); + + if (this.existingState && !this.existingState.completed) { + contentEl.createEl("p", { + text: `Resuming scan: ${this.existingState.progress.current}/${this.existingState.progress.total}`, + cls: "scan-progress-info", + }); + } + + // Controls + const controlsContainer = contentEl.createDiv({ cls: "scan-controls" }); + + // Start/Resume scan button + if (!this.isScanning && (this.items.length === 0 || !this.existingState?.completed)) { + const scanBtn = controlsContainer.createEl("button", { + text: this.items.length === 0 ? "Start Scan" : "Resume Scan", + cls: "mod-cta", + }); + scanBtn.addEventListener("click", () => { + this.startScan(); + }); + } + + // Filters + const filtersContainer = contentEl.createDiv({ cls: "scan-filters" }); + filtersContainer.createEl("h3", { text: "Filters" }); + + // Status filter + const statusSelect = filtersContainer.createEl("select"); + statusSelect.createEl("option", { text: "All", value: "" }); + statusSelect.createEl("option", { text: "Near Complete", value: "near_complete" }); + statusSelect.createEl("option", { text: "Partial", value: "partial" }); + statusSelect.createEl("option", { text: "Weak", value: "weak" }); + statusSelect.createEl("option", { text: "Deprioritized", value: "deprioritized" }); + statusSelect.value = this.filterStatus || ""; + statusSelect.addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement; + this.filterStatus = target.value || null; + this.render(); + }); + + // Gap filters + new Setting(filtersContainer) + .setName("Has Missing Slot") + .addToggle((toggle) => { + toggle.setValue(this.filterHasMissingSlot); + toggle.onChange((value) => { + this.filterHasMissingSlot = value; + this.render(); + }); + }); + + new Setting(filtersContainer) + .setName("Has Missing Link") + .addToggle((toggle) => { + toggle.setValue(this.filterHasMissingLink); + toggle.onChange((value) => { + this.filterHasMissingLink = value; + this.render(); + }); + }); + + new Setting(filtersContainer) + .setName("Weak") + .addToggle((toggle) => { + toggle.setValue(this.filterWeak); + toggle.onChange((value) => { + this.filterWeak = value; + this.render(); + }); + }); + + new Setting(filtersContainer) + .setName("Candidate Cleanup") + .addToggle((toggle) => { + toggle.setValue(this.filterCandidateCleanup); + toggle.onChange((value) => { + this.filterCandidateCleanup = value; + this.render(); + }); + }); + + // Search + filtersContainer.createEl("label", { text: "Search:" }); + const searchInput = filtersContainer.createEl("input", { + type: "text", + placeholder: "File name, heading, template...", + }); + searchInput.value = this.searchQuery; + searchInput.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + this.searchQuery = target.value.toLowerCase(); + this.render(); + }); + + // Results list + const resultsContainer = contentEl.createDiv({ cls: "scan-results" }); + resultsContainer.createEl("h3", { text: "Results" }); + + this.render(); + } + + private async startScan(): Promise { + this.isScanning = true; + this.shouldCancel = false; + this.render(); + + try { + const items = await scanVaultForChainGaps( + this.app, + this.chainRoles, + this.chainTemplates, + this.edgeVocabulary, + this.settings, + (progress) => { + // Update progress + const state: ScanState = { + items: this.items, + timestamp: Date.now(), + progress, + completed: false, + }; + saveScanState(this.app, state, this.pluginInstance); + this.render(); + }, + () => this.shouldCancel + ); + + this.items = items; + + // Save completed state + const state: ScanState = { + items, + timestamp: Date.now(), + progress: { + current: items.length, + total: items.length, + currentFile: null, + }, + completed: true, + }; + await saveScanState(this.app, state, this.pluginInstance); + + this.isScanning = false; + this.render(); + } catch (e) { + this.isScanning = false; + const msg = e instanceof Error ? e.message : String(e); + console.error("[Vault Triage Scan] Error:", e); + new Notice(`Scan failed: ${msg}`); + this.render(); + } + } + + private render(): void { + const resultsContainer = this.contentEl.querySelector(".scan-results"); + if (!resultsContainer) return; + + // Clear previous results + const existingList = resultsContainer.querySelector(".scan-items-list"); + if (existingList) { + existingList.remove(); + } + + // Filter items + let filteredItems = this.items; + + if (this.filterStatus) { + filteredItems = filteredItems.filter((item) => item.status === this.filterStatus); + } + + if (this.filterHasMissingSlot) { + filteredItems = filteredItems.filter((item) => item.gapCounts.missingSlots > 0); + } + + if (this.filterHasMissingLink) { + filteredItems = filteredItems.filter((item) => item.gapCounts.missingLinks > 0); + } + + if (this.filterWeak) { + filteredItems = filteredItems.filter((item) => item.status === "weak"); + } + + if (this.filterCandidateCleanup) { + filteredItems = filteredItems.filter((item) => item.gapCounts.candidateCleanup > 0); + } + + if (this.searchQuery) { + filteredItems = filteredItems.filter((item) => { + const fileMatch = item.file.toLowerCase().includes(this.searchQuery); + const headingMatch = item.heading?.toLowerCase().includes(this.searchQuery); + const templateMatch = item.matches.some((m) => + m.templateName.toLowerCase().includes(this.searchQuery) + ); + return fileMatch || headingMatch || templateMatch; + }); + } + + // Sort: near_complete first, then by score descending + filteredItems.sort((a, b) => { + if (a.status === "near_complete" && b.status !== "near_complete") return -1; + if (a.status !== "near_complete" && b.status === "near_complete") return 1; + + const aMaxScore = Math.max(...a.matches.map((m) => m.score)); + const bMaxScore = Math.max(...b.matches.map((m) => m.score)); + return bMaxScore - aMaxScore; + }); + + // Render list + const listContainer = resultsContainer.createDiv({ cls: "scan-items-list" }); + listContainer.createEl("p", { + text: `${filteredItems.length} item(s)${this.isScanning ? " (scanning...)" : ""}`, + }); + + for (const item of filteredItems) { + const itemEl = listContainer.createDiv({ cls: "scan-item" }); + + const headerEl = itemEl.createDiv({ cls: "scan-item-header" }); + headerEl.createEl("div", { + cls: "scan-item-title", + text: `${item.file}${item.heading ? `#${item.heading}` : ""}`, + }); + + const statusBadge = headerEl.createEl("span", { + cls: `scan-item-status status-${item.status}`, + text: item.status, + }); + + // Gap counts + const gapCountsEl = itemEl.createDiv({ cls: "scan-item-gaps" }); + if (item.gapCounts.missingSlots > 0) { + gapCountsEl.createEl("span", { + text: `Missing Slots: ${item.gapCounts.missingSlots}`, + cls: "gap-badge", + }); + } + if (item.gapCounts.missingLinks > 0) { + gapCountsEl.createEl("span", { + text: `Missing Links: ${item.gapCounts.missingLinks}`, + cls: "gap-badge", + }); + } + if (item.gapCounts.weakRoles > 0) { + gapCountsEl.createEl("span", { + text: `Weak Roles: ${item.gapCounts.weakRoles}`, + cls: "gap-badge", + }); + } + if (item.gapCounts.candidateCleanup > 0) { + gapCountsEl.createEl("span", { + text: `Candidate Cleanup: ${item.gapCounts.candidateCleanup}`, + cls: "gap-badge", + }); + } + + // Matches summary + const matchesEl = itemEl.createDiv({ cls: "scan-item-matches" }); + matchesEl.createEl("div", { + text: `${item.matches.length} template match(es)`, + }); + + // Actions + const actionsEl = itemEl.createDiv({ cls: "scan-item-actions" }); + const openBtn = actionsEl.createEl("button", { + text: "Open Workbench", + }); + openBtn.addEventListener("click", async () => { + // Open file and section, then open workbench + await this.app.workspace.openLinkText(item.file, "", true); + + // Wait a bit for file to open, then trigger workbench + setTimeout(async () => { + const { executeChainWorkbench } = await import("../commands/chainWorkbenchCommand"); + const activeFile = this.app.workspace.getActiveFile(); + const activeEditor = this.app.workspace.activeEditor?.editor; + + if (activeFile && activeEditor) { + await executeChainWorkbench( + this.app, + activeEditor, + activeFile.path, + this.chainRoles, + this.chainTemplates, + undefined, + this.settings, + this.pluginInstance + ); + } + }, 500); + }); + + const deprioritizeBtn = actionsEl.createEl("button", { + text: item.status === "deprioritized" ? "Prioritize" : "Deprioritize", + }); + deprioritizeBtn.addEventListener("click", () => { + item.status = item.status === "deprioritized" ? "near_complete" : "deprioritized"; + this.render(); + }); + } + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/workbench/interviewOrchestration.ts b/src/workbench/interviewOrchestration.ts new file mode 100644 index 0000000..ef9b037 --- /dev/null +++ b/src/workbench/interviewOrchestration.ts @@ -0,0 +1,369 @@ +/** + * Interview orchestration for workbench missing_slot todos. + */ + +import { Notice } from "obsidian"; +import type { App, TFile } from "obsidian"; +import type { MissingSlotTodo } from "./types"; +import type { InterviewConfig, InterviewProfile } from "../interview/types"; +import type { ChainTemplatesConfig } from "../dictionary/types"; +import type { MindnetSettings } from "../settings"; +import { ProfileSelectionModal } from "../ui/ProfileSelectionModal"; +import { startWizardAfterCreate } from "../unresolvedLink/unresolvedLinkHandler"; +import { writeFrontmatter } from "../interview/writeFrontmatter"; +import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "../mapping/folderHelpers"; +import type { Vocabulary } from "../vocab/Vocabulary"; +import { resolveCanonicalEdgeType } from "../analysis/chainInspector"; +import type { EdgeVocabulary } from "../vocab/types"; + +export interface InterviewOrchestrationPayload { + templateName: string; + slotId: string; + requiredEdges: Array<{ + edgeType: string; // Canonical edge type + targetNote: string; // Note file path or basename + targetHeading: string | null; // Heading if available + suggestedAlias?: string; // Optional alias suggestion + }>; +} + +/** + * Create note via interview for missing slot. + */ +export async function createNoteViaInterview( + app: App, + todo: MissingSlotTodo, + matchTemplateName: string, + match: import("../analysis/chainInspector").TemplateMatch, + interviewConfig: InterviewConfig | null, + chainTemplates: ChainTemplatesConfig | null, + chainRoles: import("../dictionary/types").ChainRolesConfig | null, + settings: MindnetSettings, + vocabulary: import("../vocab/Vocabulary").Vocabulary, + edgeVocabulary: EdgeVocabulary | null, + pluginInstance: any +): Promise { + if (!interviewConfig) { + throw new Error("Interview config required"); + } + + // Find template to get required edges for this slot + const template = chainTemplates?.templates.find((t) => t.name === matchTemplateName); + if (!template) { + throw new Error(`Template not found: ${matchTemplateName}`); + } + + // Get required edges for this slot + const requiredEdges = getRequiredEdgesForSlot( + template, + todo.slotId, + match, + chainRoles, + vocabulary, + edgeVocabulary + ); + + // Filter profiles by allowed node types + const allowedProfiles = interviewConfig.profiles.filter((profile) => { + if (todo.allowedNodeTypes.length === 0) { + return true; // No restrictions + } + return todo.allowedNodeTypes.includes(profile.note_type); + }); + + if (allowedProfiles.length === 0) { + throw new Error(`No interview profiles found for node types: ${todo.allowedNodeTypes.join(", ")}`); + } + + // Let user select profile + const profileResult = await selectProfile( + app, + interviewConfig, + allowedProfiles, + settings + ); + + if (!profileResult) { + return; // User cancelled + } + + // Build payload for interview + const payload: InterviewOrchestrationPayload = { + templateName: matchTemplateName, + slotId: todo.slotId, + requiredEdges: requiredEdges.map((edge) => ({ + edgeType: edge.edgeType, + targetNote: edge.targetNote, + targetHeading: edge.targetHeading, + suggestedAlias: edge.suggestedAlias, + })), + }; + + // Create note with minimal structure + const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + const frontmatter = writeFrontmatter({ + id, + title: profileResult.title, + noteType: profileResult.profile.note_type, + interviewProfile: profileResult.profile.key, + defaults: profileResult.profile.defaults, + frontmatterWhitelist: interviewConfig.frontmatterWhitelist, + }); + + // Build content with zones + let content = `${frontmatter}\n\n`; + if (settings.fixActions.createMissingNote.includeZones === "note_links_only" || + settings.fixActions.createMissingNote.includeZones === "both") { + content += "## Note-Verbindungen\n\n"; + } + if (settings.fixActions.createMissingNote.includeZones === "candidates_only" || + settings.fixActions.createMissingNote.includeZones === "both") { + content += "## Kandidaten\n\n"; + } + + // Ensure folder exists + const folderPath = profileResult.folderPath || settings.defaultNotesFolder || ""; + if (folderPath) { + await ensureFolderExists(app, folderPath); + } + + // Build file path + const fileName = `${profileResult.title}.md`; + const desiredPath = joinFolderAndBasename(folderPath, fileName); + const filePath = await ensureUniqueFilePath(app, desiredPath); + + // Create file + const file = await app.vault.create(filePath, content); + + // Open file + await app.workspace.openLinkText(filePath, "", true); + + // Convert payload.requiredEdges to PendingEdgeAssignment format for wizard + // The wizard will use these as quick-insert options + const pendingEdgeAssignments = payload.requiredEdges.map((edge) => { + // Get section key for target note (we'll use ROOT for now, could be improved) + const sectionKey = edge.targetHeading ? `H2:${edge.targetHeading}` : "ROOT"; + + return { + filePath: file.path, + sectionKey: "ROOT", // Source section (new note, root section) + linkBasename: edge.targetNote, + chosenRawType: edge.suggestedAlias || edge.edgeType, // Use alias if available, otherwise canonical + createdAt: Date.now(), + }; + }); + + // Start wizard with payload + // Note: The wizard system doesn't directly accept pendingEdgeAssignments in startWizardAfterCreate + // We'll need to inject them into the wizard state after creation + // For now, we'll rely on soft validation after wizard completion + await startWizardAfterCreate( + app, + settings, + file, + profileResult.profile, + content, + false, + async (result) => { + // Soft validation: check if requiredEdges are set + await validateRequiredEdges( + app, + file, + payload, + vocabulary, + edgeVocabulary + ); + new Notice("Wizard completed"); + }, + async (result) => { + // Soft validation on save too + await validateRequiredEdges( + app, + file, + payload, + vocabulary, + edgeVocabulary + ); + new Notice("Wizard saved"); + }, + pluginInstance + ); + + // TODO: Inject pendingEdgeAssignments into wizard state + // This would require modifying InterviewWizardModal to accept initial pendingEdgeAssignments + // For MVP, we rely on soft validation and user can manually add edges +} + +/** + * Get required edges for a slot from template and match context. + */ +function getRequiredEdgesForSlot( + template: import("../dictionary/types").ChainTemplate, + slotId: string, + match: import("../analysis/chainInspector").TemplateMatch, + chainRoles: import("../dictionary/types").ChainRolesConfig | null, + vocabulary: Vocabulary, + edgeVocabulary: EdgeVocabulary | null +): Array<{ + edgeType: string; + targetNote: string; + targetHeading: string | null; + suggestedAlias?: string; +}> { + const requiredEdges: Array<{ + edgeType: string; + targetNote: string; + targetHeading: string | null; + suggestedAlias?: string; + }> = []; + + // Find links that target this slot (incoming edges) + const normalizedLinks = template.links || []; + for (const link of normalizedLinks) { + if (link.to === slotId) { + // This link targets our slot + // Find source slot assignment to get the source note + const sourceAssignment = match.slotAssignments[link.from]; + + if (!sourceAssignment) { + // Source slot not filled yet, skip + continue; + } + + // Get suggested edge types from chain_roles for allowed_edge_roles + const suggestedEdgeTypes: string[] = []; + if (chainRoles && link.allowed_edge_roles) { + for (const roleName of link.allowed_edge_roles) { + const role = chainRoles.roles[roleName]; + if (role && role.edge_types) { + suggestedEdgeTypes.push(...role.edge_types); + } + } + } + + // Use first suggested type as default (canonical) + const defaultEdgeType = suggestedEdgeTypes[0] || "related_to"; + + // Get alias suggestion if available + let suggestedAlias: string | undefined; + if (edgeVocabulary) { + const entry = edgeVocabulary.byCanonical.get(defaultEdgeType); + if (entry && entry.aliases.length > 0) { + suggestedAlias = entry.aliases[0]; + } + } + + // Build target link format + const targetNote = sourceAssignment.file.replace(/\.md$/, ""); + const targetHeading = sourceAssignment.heading || null; + + requiredEdges.push({ + edgeType: defaultEdgeType, + targetNote, + targetHeading, + suggestedAlias, + }); + } + } + + return requiredEdges; +} + +/** + * Select interview profile. + */ +async function selectProfile( + app: App, + interviewConfig: InterviewConfig, + allowedProfiles: InterviewProfile[], + settings: MindnetSettings +): Promise<{ profile: InterviewProfile; title: string; folderPath: string } | null> { + return new Promise((resolve) => { + // Filter config to only allowed profiles + const filteredConfig: InterviewConfig = { + ...interviewConfig, + profiles: allowedProfiles, + }; + + // Use ProfileSelectionModal with filtered profiles + const modal = new ProfileSelectionModal( + app, + filteredConfig, + async (result) => { + resolve({ + profile: result.profile, + title: result.title, + folderPath: result.folderPath || settings.defaultNotesFolder || "", + }); + }, + "", // Default title (user will set) + settings.defaultNotesFolder || "" + ); + + // Filter profiles in modal (we'll need to modify ProfileSelectionModal or filter here) + // For now, we'll pass all profiles and let user choose + modal.onClose = () => { + resolve(null); + }; + modal.open(); + }); +} + +/** + * Soft validation: check if requiredEdges are set. + */ +async function validateRequiredEdges( + app: App, + file: TFile, + payload: InterviewOrchestrationPayload, + vocabulary: Vocabulary, + edgeVocabulary: EdgeVocabulary | null +): Promise { + const content = await app.vault.read(file); + + // Parse edges from content + const { parseEdgesFromCallouts } = await import("../parser/parseEdgesFromCallouts"); + const parsedEdges = parseEdgesFromCallouts(content); + + // Check each required edge + const missingEdges: string[] = []; + + for (const requiredEdge of payload.requiredEdges) { + // Skip if targetNote is empty (not yet determined from context) + if (!requiredEdge.targetNote) { + continue; + } + + // Check if edge exists + const edgeExists = parsedEdges.some((parsedEdge) => { + // Check canonical edge type match + const canonical = edgeVocabulary + ? resolveCanonicalEdgeType(parsedEdge.rawType, edgeVocabulary).canonical + : null; + + if (!canonical || canonical !== requiredEdge.edgeType) { + return false; + } + + // Check target match + return parsedEdge.targets.some((target) => { + const normalizedTarget = target.split("#")[0]?.split("|")[0]?.trim() || target; + return normalizedTarget === requiredEdge.targetNote; + }); + }); + + if (!edgeExists) { + missingEdges.push( + `${requiredEdge.edgeType} -> ${requiredEdge.targetNote}${requiredEdge.targetHeading ? `#${requiredEdge.targetHeading}` : ""}` + ); + } + } + + if (missingEdges.length > 0 && app.workspace.getActiveFile()?.path === file.path) { + // Show soft validation notice (not blocking) + new Notice( + `Note created, but ${missingEdges.length} required edge(s) still missing: ${missingEdges.slice(0, 2).join(", ")}${missingEdges.length > 2 ? "..." : ""}`, + 5000 + ); + } +} diff --git a/src/workbench/linkExistingAction.ts b/src/workbench/linkExistingAction.ts new file mode 100644 index 0000000..9b4da25 --- /dev/null +++ b/src/workbench/linkExistingAction.ts @@ -0,0 +1,78 @@ +/** + * Link existing action for missing_slot todos. + */ + +import { Notice } from "obsidian"; +import type { App, Editor, TFile } from "obsidian"; +import type { MissingSlotTodo } from "./types"; +import { EntityPickerModal } from "../ui/EntityPickerModal"; +import { NoteIndex } from "../entityPicker/noteIndex"; +import { findSection } from "./zoneDetector"; + +/** + * Link existing note/section to fill missing slot. + */ +export async function linkExisting( + app: App, + editor: Editor, + file: TFile, + todo: MissingSlotTodo, + context: { file: string; heading: string | null } +): Promise { + // Show note picker + const noteIndex = new NoteIndex(app); + + const selectedNote = await new Promise<{ basename: string; path: string } | null>( + (resolve) => { + const modal = new EntityPickerModal( + app, + noteIndex, + (result) => { + modal.close(); + resolve(result); + } + ); + modal.onClose = () => { + resolve(null); + }; + modal.open(); + } + ); + + if (!selectedNote) { + return; // User cancelled + } + + // Filter by allowed node types if specified + // For now, we'll just show all notes and let user choose + // TODO: Filter noteIndex by noteType if allowedNodeTypes specified + + // User selected a note, now we need to: + // 1. Determine if they want to link to a specific section or the whole note + // 2. Insert the link in the current section + + // For now, we'll insert a simple link + // The actual slot assignment would need to be done via template matching + // This is a placeholder - the real implementation would need to update the match + + const targetNote = selectedNote.basename.replace(/\.md$/, ""); + const linkText = `[[${targetNote}]]`; + + // Insert link at cursor position + const cursor = editor.getCursor(); + const line = editor.getLine(cursor.line); + const beforeCursor = line.substring(0, cursor.ch); + const afterCursor = line.substring(cursor.ch); + + // Insert link + const newLine = beforeCursor + linkText + afterCursor; + editor.setLine(cursor.line, newLine); + + // Move cursor after link + editor.setCursor({ + line: cursor.line, + ch: cursor.ch + linkText.length, + }); + + new Notice(`Linked to ${targetNote}. Note: This is a placeholder - slot assignment requires re-running workbench.`); +} diff --git a/src/workbench/statusCalculator.ts b/src/workbench/statusCalculator.ts new file mode 100644 index 0000000..422f67b --- /dev/null +++ b/src/workbench/statusCalculator.ts @@ -0,0 +1,99 @@ +/** + * Calculate match status for workbench matches. + */ + +import type { TemplateMatch } from "../analysis/chainInspector"; +import type { MatchStatus, WorkbenchMatch } from "./types"; + +/** + * Calculate status for a template match. + */ +export function calculateMatchStatus(match: TemplateMatch, hasWeakRoles: boolean): MatchStatus { + const slotsFilled = Object.keys(match.slotAssignments).length; + const slotsTotal = slotsFilled + match.missingSlots.length; + const linksSatisfied = match.satisfiedLinks; + const linksRequired = match.requiredLinks; + + // complete: slotsFilled==slotsTotal && linksSatisfied==linksRequired + if (slotsFilled === slotsTotal && linksSatisfied === linksRequired) { + return "complete"; + } + + // near_complete: + // a) slotsFilled==slotsTotal && (linksRequired-linksSatisfied) in {1,2} + // ODER + // b) (slotsTotal-slotsFilled)==1 && linksSatisfied==linksRequired + if ( + (slotsFilled === slotsTotal && linksRequired - linksSatisfied >= 1 && linksRequired - linksSatisfied <= 2) || + (slotsTotal - slotsFilled === 1 && linksSatisfied === linksRequired) + ) { + return "near_complete"; + } + + // weak: + // Match vorhanden, aber Findings enthalten weak_chain_roles / no_causal_roles + // oder die erfüllten Verbindungen sind überwiegend structural/temporal ohne causal/constraints + if (hasWeakRoles) { + return "weak"; + } + + // partial: sonstige Fälle mit mindestens einer Lücke + return "partial"; +} + +/** + * Calculate slots filled/total for a match. + */ +export function calculateSlotsStats(match: TemplateMatch): { filled: number; total: number } { + const filled = Object.keys(match.slotAssignments).length; + const total = filled + match.missingSlots.length; + return { filled, total }; +} + +/** + * Check if match has weak roles. + */ +export function hasWeakRoles(match: TemplateMatch): boolean { + // Check roleEvidence: if all roles are structural/temporal and no causal/constraints + if (!match.roleEvidence || match.roleEvidence.length === 0) { + return false; + } + + const causalRoles = ["causal", "influences", "enables_constraints"]; + const structuralTemporalRoles = ["structural", "temporal", "epistemic", "normative"]; + + const hasCausalRole = match.roleEvidence.some((ev) => causalRoles.includes(ev.edgeRole)); + const allStructuralTemporal = match.roleEvidence.every((ev) => + structuralTemporalRoles.includes(ev.edgeRole) + ); + + // Weak if no causal roles and all are structural/temporal + return !hasCausalRole && allStructuralTemporal; +} + +/** + * Determine effective required_links value. + */ +export function getEffectiveRequiredLinks( + match: TemplateMatch, + profileRequiredLinks?: boolean, + templateRequiredLinks?: boolean, + defaultRequiredLinks?: boolean +): boolean { + // Resolution Order: + // 1. template.matching?.required_links + // 2. profile.required_links + // 3. defaults.matching?.required_links + // 4. Fallback: false + + if (templateRequiredLinks !== undefined) { + return templateRequiredLinks; + } + if (profileRequiredLinks !== undefined) { + return profileRequiredLinks; + } + if (defaultRequiredLinks !== undefined) { + return defaultRequiredLinks; + } + return false; +} diff --git a/src/workbench/todoGenerator.ts b/src/workbench/todoGenerator.ts new file mode 100644 index 0000000..adb5abd --- /dev/null +++ b/src/workbench/todoGenerator.ts @@ -0,0 +1,491 @@ +/** + * Generate todos for workbench matches. + */ + +import type { App } from "obsidian"; +import type { TemplateMatch, NeighborEdge, Finding } from "../analysis/chainInspector"; +import type { IndexedEdge } from "../analysis/graphIndex"; +import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; +import type { + WorkbenchTodoUnion, + MissingSlotTodo, + MissingLinkTodo, + WeakRolesTodo, + CandidateCleanupTodo, + DanglingTargetTodo, + DanglingTargetHeadingTodo, + OnlyCandidatesTodo, + MissingEdgesTodo, + NoCausalRolesTodo, + OneSidedConnectivityTodo, +} from "./types"; +import { resolveCanonicalEdgeType } from "../analysis/chainInspector"; +import type { EdgeVocabulary } from "../vocab/types"; + +/** + * Generate todos for a template match. + */ +export async function generateTodos( + app: App, + match: TemplateMatch, + template: import("../dictionary/types").ChainTemplate, + chainRoles: ChainRolesConfig | null, + edgeVocabulary: EdgeVocabulary | null, + allEdges: IndexedEdge[], + candidateEdges: IndexedEdge[], + confirmedEdges: IndexedEdge[], + context: { file: string; heading: string | null } +): Promise { + const todos: WorkbenchTodoUnion[] = []; + + // 1. Missing slot todos + for (const slotId of match.missingSlots) { + const slot = template.slots.find((s) => (typeof s === "string" ? s === slotId : s.id === slotId)); + if (slot) { + const allowedNodeTypes = typeof slot === "string" ? [] : slot.allowed_node_types || []; + todos.push({ + type: "missing_slot", + id: `missing_slot_${match.templateName}_${slotId}`, + description: `Missing slot "${slotId}" for template "${match.templateName}"`, + priority: "high", + slotId, + allowedNodeTypes, + actions: ["link_existing", "create_note_via_interview"], + } as MissingSlotTodo); + } + } + + // 2. Missing link todos + // Get normalized template links + const normalizedLinks = template.links || []; + const normalizedSlots = template.slots.map((s) => + typeof s === "string" ? { id: s, allowed_node_types: [] } : s + ); + + for (const link of normalizedLinks) { + const fromSlot = normalizedSlots.find((s) => s.id === link.from); + const toSlot = normalizedSlots.find((s) => s.id === link.to); + + if (!fromSlot || !toSlot) continue; + + const fromAssignment = match.slotAssignments[link.from]; + const toAssignment = match.slotAssignments[link.to]; + + // Only generate todo if both slots are filled but link is missing + if (fromAssignment && toAssignment) { + // Check if link exists in roleEvidence + // roleEvidence uses slot IDs (e.g., "learning", "behavior"), not file paths + const linkExists = match.roleEvidence?.some( + (ev) => + ev.from === link.from && ev.to === link.to + ); + + console.log(`[todoGenerator] Checking link ${link.from} -> ${link.to}:`, { + linkExists, + roleEvidence: match.roleEvidence?.filter(ev => ev.from === link.from && ev.to === link.to) + }); + + if (!linkExists) { + // Get suggested edge types from chain_roles for allowed_edge_roles + const suggestedEdgeTypes: string[] = []; + if (chainRoles && link.allowed_edge_roles) { + for (const roleName of link.allowed_edge_roles) { + const role = chainRoles.roles[roleName]; + if (role && role.edge_types) { + suggestedEdgeTypes.push(...role.edge_types); + } + } + } + + todos.push({ + type: "missing_link", + id: `missing_link_${match.templateName}_${link.from}_${link.to}`, + description: `Missing link from "${link.from}" to "${link.to}" for template "${match.templateName}"`, + priority: "high", + fromSlotId: link.from, + toSlotId: link.to, + fromNodeRef: { + file: fromAssignment.file, + heading: fromAssignment.heading || null, + noteType: fromAssignment.noteType, + }, + toNodeRef: { + file: toAssignment.file, + heading: toAssignment.heading || null, + noteType: toAssignment.noteType, + }, + allowedEdgeRoles: link.allowed_edge_roles || [], + suggestedEdgeTypes: [...new Set(suggestedEdgeTypes)], // Deduplicate + actions: ["insert_edge_forward", "insert_edge_inverse", "choose_target_anchor"], + } as MissingLinkTodo); + } + } + } + + // 3. Weak roles todos + if (match.roleEvidence && match.roleEvidence.length > 0) { + const causalRoles = ["causal", "influences", "enables_constraints"]; + const hasCausalRole = match.roleEvidence.some((ev) => causalRoles.includes(ev.edgeRole)); + + if (!hasCausalRole) { + // Check if all roles are structural/temporal + const structuralTemporalRoles = ["structural", "temporal", "epistemic", "normative"]; + const allStructuralTemporal = match.roleEvidence.every((ev) => + structuralTemporalRoles.includes(ev.edgeRole) + ); + + if (allStructuralTemporal) { + const weakEdges = match.roleEvidence.map((ev) => { + // Find corresponding edge in allEdges + const edge = allEdges.find( + (e) => + e.rawEdgeType === ev.rawEdgeType && + `${e.source.file}:${"sectionHeading" in e.source ? e.source.sectionHeading || "" : ""}` === ev.from && + `${e.target.file}:${e.target.heading || ""}` === ev.to + ); + + return { + rawEdgeType: ev.rawEdgeType, + from: edge + ? "sectionHeading" in edge.source + ? { file: edge.source.file, heading: edge.source.sectionHeading } + : { file: edge.source.file, heading: null } + : { file: "", heading: null }, + to: edge ? edge.target : { file: "", heading: null }, + currentRole: ev.edgeRole, + suggestedRoles: causalRoles, + }; + }); + + todos.push({ + type: "weak_roles", + id: `weak_roles_${match.templateName}`, + description: `Template "${match.templateName}" has only structural/temporal roles, no causal roles`, + priority: "medium", + edges: weakEdges, + actions: ["change_edge_type"], + } as WeakRolesTodo); + } + } + } + + // 4. Candidate cleanup todos + // Check if any candidate edges match missing link requirements + // Rule: If a confirmed edge already exists for the same relation, don't suggest candidate cleanup + for (const todo of todos) { + if (todo.type === "missing_link") { + const missingLinkTodo = todo as MissingLinkTodo; + + // Check if confirmed edge already exists (relation equality) + const confirmedExists = checkRelationExists( + confirmedEdges, + missingLinkTodo.fromNodeRef, + missingLinkTodo.toNodeRef, + missingLinkTodo.suggestedEdgeTypes, + edgeVocabulary + ); + + if (confirmedExists) { + // Skip candidate cleanup if confirmed edge exists + continue; + } + + // Find matching candidate edge + // Relation-Equality: same direction, same sourceRef/targetRef, canonical edgeType equal + const matchingCandidate = candidateEdges.find((candidate) => { + // Build keys for comparison + const candidateFromKey = `${ + "sectionHeading" in candidate.source ? candidate.source.file : candidate.source.file + }:${"sectionHeading" in candidate.source ? candidate.source.sectionHeading || "" : ""}`; + const candidateToKey = `${candidate.target.file}:${candidate.target.heading || ""}`; + + const requiredFromKey = `${missingLinkTodo.fromNodeRef.file}:${missingLinkTodo.fromNodeRef.heading || ""}`; + const requiredToKey = `${missingLinkTodo.toNodeRef.file}:${missingLinkTodo.toNodeRef.heading || ""}`; + + // Check direction and node refs match + if (candidateFromKey !== requiredFromKey || candidateToKey !== requiredToKey) { + return false; + } + + // Check canonical edge type match (internally canonical, but user can choose alias) + const candidateCanonical = edgeVocabulary + ? resolveCanonicalEdgeType(candidate.rawEdgeType, edgeVocabulary).canonical + : null; + + if (!candidateCanonical) { + return false; + } + + // Check if any suggested edge type matches (canonical comparison) + const matchesEdgeType = missingLinkTodo.suggestedEdgeTypes.some((suggested) => { + const suggestedCanonical = edgeVocabulary + ? resolveCanonicalEdgeType(suggested, edgeVocabulary).canonical + : null; + // Match if canonical types are equal + return candidateCanonical === suggestedCanonical || candidateCanonical === suggested; + }); + + return matchesEdgeType; + }); + + if (matchingCandidate) { + todos.push({ + type: "candidate_cleanup", + id: `candidate_cleanup_${match.templateName}_${missingLinkTodo.fromSlotId}_${missingLinkTodo.toSlotId}`, + description: `Candidate edge matches missing link requirement for "${match.templateName}"`, + priority: "medium", + candidateEdge: { + rawEdgeType: matchingCandidate.rawEdgeType, + from: "sectionHeading" in matchingCandidate.source + ? { file: matchingCandidate.source.file, heading: matchingCandidate.source.sectionHeading } + : { file: matchingCandidate.source.file, heading: null }, + to: matchingCandidate.target, + evidence: matchingCandidate.evidence, + }, + neededFor: { + matchName: match.templateName, + linkFromSlot: missingLinkTodo.fromSlotId, + linkToSlot: missingLinkTodo.toSlotId, + }, + actions: ["promote_candidate", "resolve_candidate"], + } as CandidateCleanupTodo); + } + } + } + + return todos; +} + +/** + * Check if a relation already exists in confirmed edges (relation equality). + */ +function checkRelationExists( + confirmedEdges: IndexedEdge[], + fromRef: { file: string; heading: string | null }, + toRef: { file: string; heading: string | null }, + suggestedEdgeTypes: string[], + edgeVocabulary: EdgeVocabulary | null +): boolean { + const fromKey = `${fromRef.file}:${fromRef.heading || ""}`; + const toKey = `${toRef.file}:${toRef.heading || ""}`; + + for (const edge of confirmedEdges) { + // Check direction and node refs + const edgeFromKey = `${ + "sectionHeading" in edge.source ? edge.source.file : edge.source.file + }:${"sectionHeading" in edge.source ? edge.source.sectionHeading || "" : ""}`; + const edgeToKey = `${edge.target.file}:${edge.target.heading || ""}`; + + if (edgeFromKey !== fromKey || edgeToKey !== toKey) { + continue; + } + + // Check canonical edge type match + const edgeCanonical = edgeVocabulary + ? resolveCanonicalEdgeType(edge.rawEdgeType, edgeVocabulary).canonical + : null; + + if (!edgeCanonical) { + continue; + } + + // Check if matches any suggested type (canonical comparison) + const matches = suggestedEdgeTypes.some((suggested) => { + const suggestedCanonical = edgeVocabulary + ? resolveCanonicalEdgeType(suggested, edgeVocabulary).canonical + : null; + return edgeCanonical === suggestedCanonical || edgeCanonical === suggested; + }); + + if (matches) { + return true; + } + } + + return false; +} + +/** + * Generate todos from Chain Inspector findings. + */ +export function generateFindingsTodos( + findings: Finding[], + allEdges: IndexedEdge[], + context: { file: string; heading: string | null }, + chainRoles: ChainRolesConfig | null, + edgeVocabulary: EdgeVocabulary | null +): WorkbenchTodoUnion[] { + const todos: WorkbenchTodoUnion[] = []; + + for (const finding of findings) { + switch (finding.code) { + case "dangling_target": { + // Find edge with dangling target + const danglingEdges = allEdges.filter((e) => { + // Check if edge targets a non-existent file + // The finding evidence points to the source section + const sourceMatches = + "sectionHeading" in e.source + ? e.source.sectionHeading === finding.evidence?.sectionHeading && + e.source.file === finding.evidence?.file + : e.source.file === finding.evidence?.file; + return sourceMatches; + }); + + for (const edge of danglingEdges) { + todos.push({ + type: "dangling_target", + id: `dangling_target_${edge.target.file}_${Date.now()}`, + description: finding.message, + priority: finding.severity === "error" ? "high" : "medium", + targetFile: edge.target.file, + targetHeading: edge.target.heading, + sourceEdge: { + file: "sectionHeading" in edge.source ? edge.source.file : edge.source.file, + heading: "sectionHeading" in edge.source ? edge.source.sectionHeading || null : null, + }, + actions: ["create_missing_note", "retarget_link"], + } as DanglingTargetTodo); + } + break; + } + + case "dangling_target_heading": { + todos.push({ + type: "dangling_target_heading", + id: `dangling_target_heading_${finding.evidence?.file || ""}_${finding.evidence?.sectionHeading || ""}_${Date.now()}`, + description: finding.message, + priority: finding.severity === "warn" ? "medium" : "low", + targetFile: finding.evidence?.file || "", + targetHeading: finding.evidence?.sectionHeading || null, + actions: ["create_missing_heading", "retarget_to_existing_heading"], + } as DanglingTargetHeadingTodo); + break; + } + + case "only_candidates": { + // Find all candidate edges in section + const candidateEdges = allEdges.filter( + (e) => + e.scope === "candidate" && + ("sectionHeading" in e.source + ? e.source.sectionHeading === context.heading && e.source.file === context.file + : false) + ); + + if (candidateEdges.length > 0) { + todos.push({ + type: "only_candidates", + id: `only_candidates_${context.file}_${context.heading || ""}`, + description: finding.message, + priority: "medium", + candidateEdges: candidateEdges.map((e) => ({ + rawEdgeType: e.rawEdgeType, + from: "sectionHeading" in e.source + ? { file: e.source.file, heading: e.source.sectionHeading || null } + : { file: e.source.file, heading: null }, + to: e.target, + })), + actions: ["promote_all_candidates", "create_explicit_edges"], + } as OnlyCandidatesTodo); + } + break; + } + + case "missing_edges": { + todos.push({ + type: "missing_edges", + id: `missing_edges_${context.file}_${context.heading || ""}`, + description: finding.message, + priority: "medium", + section: { + file: context.file, + heading: context.heading, + }, + actions: ["add_edges_to_section"], + } as MissingEdgesTodo); + break; + } + + case "no_causal_roles": { + // Find edges without causal roles in the section + const nonCausalEdges = allEdges.filter( + (e) => + e.scope === "section" && + ("sectionHeading" in e.source + ? e.source.sectionHeading === context.heading && e.source.file === context.file + : false) + ); + + if (nonCausalEdges.length > 0 && chainRoles) { + // Resolve roles for each edge + const edgesWithRoles = nonCausalEdges.map((e) => { + let currentRole: string | null = null; + + // Try to find role via chain_roles + if (edgeVocabulary) { + const { canonical } = resolveCanonicalEdgeType(e.rawEdgeType, edgeVocabulary); + for (const [roleName, role] of Object.entries(chainRoles.roles || {})) { + if (role.edge_types.includes(canonical || e.rawEdgeType)) { + currentRole = roleName; + break; + } + } + } + + return { + rawEdgeType: e.rawEdgeType, + from: "sectionHeading" in e.source + ? { file: e.source.file, heading: e.source.sectionHeading || null } + : { file: e.source.file, heading: null }, + to: e.target, + currentRole, + suggestedRoles: ["causal", "influences", "enables_constraints"], + }; + }); + + todos.push({ + type: "no_causal_roles", + id: `no_causal_roles_${context.file}_${context.heading || ""}`, + description: finding.message, + priority: "medium", + edges: edgesWithRoles, + actions: ["change_edge_type"], + } as NoCausalRolesTodo); + } + break; + } + + case "one_sided_connectivity": { + // Informational only + todos.push({ + type: "one_sided_connectivity", + id: `one_sided_connectivity_${context.file}_${context.heading || ""}`, + description: finding.message, + priority: "low", + section: { + file: context.file, + heading: context.heading, + }, + actions: [], + } as OneSidedConnectivityTodo); + break; + } + + // Skip template-based findings (they are handled by generateTodos) + case "missing_slot_trigger": + case "missing_slot_transformation": + case "missing_slot_outcome": + case "missing_link_constraints": + case "weak_chain_roles": + // These are template-based and handled separately + break; + + default: + // Unknown finding code - skip + break; + } + } + + return todos; +} diff --git a/src/workbench/types.ts b/src/workbench/types.ts new file mode 100644 index 0000000..c81e2b8 --- /dev/null +++ b/src/workbench/types.ts @@ -0,0 +1,233 @@ +/** + * Chain Workbench Types + */ + +import type { TemplateMatch } from "../analysis/chainInspector"; + +/** + * Status of a template match. + */ +export type MatchStatus = "complete" | "near_complete" | "partial" | "weak"; + +/** + * Workbench Match extends TemplateMatch with status and todos. + */ +export interface WorkbenchMatch extends TemplateMatch { + status: MatchStatus; + slotsFilled: number; + slotsTotal: number; + effectiveRequiredLinks: boolean; + todos: WorkbenchTodo[]; +} + +/** + * Todo types for workbench. + */ +export type TodoType = + | "missing_slot" + | "missing_link" + | "weak_roles" + | "candidate_cleanup" + | "create_candidates_zone" + | "create_note_links_zone" + | "dangling_target" + | "dangling_target_heading" + | "only_candidates" + | "missing_edges" + | "no_causal_roles" + | "one_sided_connectivity"; + +/** + * Base todo interface. + */ +export interface WorkbenchTodo { + type: TodoType; + id: string; // Unique ID for this todo + description: string; + priority: "high" | "medium" | "low"; +} + +/** + * Missing slot todo. + */ +export interface MissingSlotTodo extends WorkbenchTodo { + type: "missing_slot"; + slotId: string; + allowedNodeTypes: string[]; + actions: Array<"link_existing" | "create_note_via_interview">; +} + +/** + * Missing link todo. + */ +export interface MissingLinkTodo extends WorkbenchTodo { + type: "missing_link"; + fromSlotId: string; + toSlotId: string; + fromNodeRef: { + file: string; + heading: string | null; + noteType: string; + }; + toNodeRef: { + file: string; + heading: string | null; + noteType: string; + }; + allowedEdgeRoles: string[]; + suggestedEdgeTypes: string[]; // Canonical types from chain_roles + actions: Array<"insert_edge_forward" | "insert_edge_inverse" | "choose_target_anchor">; +} + +/** + * Weak roles todo. + */ +export interface WeakRolesTodo extends WorkbenchTodo { + type: "weak_roles"; + edges: Array<{ + rawEdgeType: string; + from: { file: string; heading: string | null }; + to: { file: string; heading: string | null }; + currentRole: string | null; + suggestedRoles: string[]; // causal, enables_constraints + }>; + actions: Array<"change_edge_type">; +} + +/** + * Candidate cleanup todo. + */ +export interface CandidateCleanupTodo extends WorkbenchTodo { + type: "candidate_cleanup"; + candidateEdge: { + rawEdgeType: string; + from: { file: string; heading: string | null }; + to: { file: string; heading: string | null }; + evidence: { + file: string; + sectionHeading: string | null; + lineRange?: { start: number; end: number }; + }; + }; + neededFor: { + matchName: string; + linkFromSlot: string; + linkToSlot: string; + }; + actions: Array<"promote_candidate" | "resolve_candidate">; +} + +/** + * Create zone todo. + */ +export interface CreateZoneTodo extends WorkbenchTodo { + type: "create_candidates_zone" | "create_note_links_zone"; + zoneType: "candidates" | "note_links"; + file: string; +} + +/** + * Dangling target todo (from findings). + */ +export interface DanglingTargetTodo extends WorkbenchTodo { + type: "dangling_target"; + targetFile: string; + targetHeading: string | null; + sourceEdge: { + file: string; + heading: string | null; + }; + actions: Array<"create_missing_note" | "retarget_link">; +} + +/** + * Dangling target heading todo (from findings). + */ +export interface DanglingTargetHeadingTodo extends WorkbenchTodo { + type: "dangling_target_heading"; + targetFile: string; + targetHeading: string | null; + actions: Array<"create_missing_heading" | "retarget_to_existing_heading">; +} + +/** + * Only candidates todo (from findings). + */ +export interface OnlyCandidatesTodo extends WorkbenchTodo { + type: "only_candidates"; + candidateEdges: Array<{ + rawEdgeType: string; + from: { file: string; heading: string | null }; + to: { file: string; heading: string | null }; + }>; + actions: Array<"promote_all_candidates" | "create_explicit_edges">; +} + +/** + * Missing edges todo (from findings). + */ +export interface MissingEdgesTodo extends WorkbenchTodo { + type: "missing_edges"; + section: { + file: string; + heading: string | null; + }; + actions: Array<"add_edges_to_section">; +} + +/** + * No causal roles todo (from findings). + */ +export interface NoCausalRolesTodo extends WorkbenchTodo { + type: "no_causal_roles"; + edges: Array<{ + rawEdgeType: string; + from: { file: string; heading: string | null }; + to: { file: string; heading: string | null }; + currentRole: string | null; + suggestedRoles: string[]; + }>; + actions: Array<"change_edge_type">; +} + +/** + * One-sided connectivity todo (from findings, informational only). + */ +export interface OneSidedConnectivityTodo extends WorkbenchTodo { + type: "one_sided_connectivity"; + section: { + file: string; + heading: string | null; + }; + actions: []; // Informational only +} + +/** + * Union type for all todos. + */ +export type WorkbenchTodoUnion = + | MissingSlotTodo + | MissingLinkTodo + | WeakRolesTodo + | CandidateCleanupTodo + | CreateZoneTodo + | DanglingTargetTodo + | DanglingTargetHeadingTodo + | OnlyCandidatesTodo + | MissingEdgesTodo + | NoCausalRolesTodo + | OneSidedConnectivityTodo; + +/** + * Workbench model containing all matches and their todos. + */ +export interface WorkbenchModel { + context: { + file: string; + heading: string | null; + zoneKind: string; + }; + matches: WorkbenchMatch[]; + globalTodos?: WorkbenchTodoUnion[]; // Todos from findings (not template-based) + timestamp: number; +} diff --git a/src/workbench/vaultTriageScan.ts b/src/workbench/vaultTriageScan.ts new file mode 100644 index 0000000..40e9b28 --- /dev/null +++ b/src/workbench/vaultTriageScan.ts @@ -0,0 +1,245 @@ +/** + * Vault Triage Scan - Scan vault for chain gaps and generate backlog. + */ + +import type { App, TFile } from "obsidian"; +import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; +import type { MindnetSettings } from "../settings"; +import type { EdgeVocabulary } from "../vocab/types"; +import { inspectChains } from "../analysis/chainInspector"; +import { buildNoteIndex } from "../analysis/graphIndex"; +import type { SectionContext } from "../analysis/sectionContext"; +import { splitIntoSections } from "../mapping/sectionParser"; +import { buildWorkbenchModel } from "./workbenchBuilder"; +import type { WorkbenchMatch } from "./types"; + +export interface ScanItem { + file: string; + heading: string | null; + matches: Array<{ + templateName: string; + status: string; + score: number; + slotsFilled: number; + slotsTotal: number; + satisfiedLinks: number; + requiredLinks: number; + todosCount: number; + }>; + gapCounts: { + missingSlots: number; + missingLinks: number; + weakRoles: number; + candidateCleanup: number; + }; + status: "near_complete" | "partial" | "weak" | "deprioritized"; +} + +export interface ScanState { + items: ScanItem[]; + timestamp: number; + progress: { + current: number; + total: number; + currentFile: string | null; + }; + completed: boolean; +} + +/** + * Scan vault for chain gaps. + */ +export async function scanVaultForChainGaps( + app: App, + chainRoles: ChainRolesConfig | null, + chainTemplates: ChainTemplatesConfig | null, + edgeVocabulary: EdgeVocabulary | null, + settings: MindnetSettings, + onProgress?: (progress: { current: number; total: number; currentFile: string | null }) => void, + shouldCancel?: () => boolean +): Promise { + const items: ScanItem[] = []; + + // Get all markdown files + const allFiles = app.vault.getMarkdownFiles(); + const total = allFiles.length; + + for (let i = 0; i < allFiles.length; i++) { + const file = allFiles[i]; + if (!file) continue; + + // Check if should cancel + if (shouldCancel && shouldCancel()) { + break; + } + + // Report progress + if (onProgress) { + onProgress({ + current: i + 1, + total, + currentFile: file.path, + }); + } + + try { + // Read file content + const content = await app.vault.read(file); + + // Split into sections + const sections = splitIntoSections(content); + + // Process each section + for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + const section = sections[sectionIndex]; + if (!section) continue; + + // Skip special zones + if (section.heading === "Kandidaten" || section.heading === "Note-Verbindungen") { + continue; + } + + // Create context directly from section + const context: SectionContext = { + file: file.path, + heading: section.heading, + zoneKind: section.heading === null + ? "root" + : section.heading === "Note-Verbindungen" + ? "note_links" + : section.heading === "Kandidaten" + ? "candidates" + : "content", + sectionIndex: sectionIndex, + }; + + // Build inspector options + const inspectorOptions = { + includeNoteLinks: true, + includeCandidates: settings.chainInspectorIncludeCandidates, + maxDepth: 3, + direction: "both" as const, + maxTemplateMatches: undefined, // No limit + }; + + // Prepare templates source info + const templatesSourceInfo = { + path: settings.chainTemplatesPath || "", + status: "loaded" as const, + loadedAt: Date.now(), + templateCount: chainTemplates?.templates?.length || 0, + }; + + // Inspect chains + const report = await inspectChains( + app, + context, + inspectorOptions, + chainRoles, + settings.edgeVocabularyPath, + chainTemplates, + templatesSourceInfo, + settings.templateMatchingProfile + ); + + // Build edges index + const { edges: allEdges } = await buildNoteIndex(app, file); + + // Build workbench model + const workbenchModel = await buildWorkbenchModel( + app, + report, + chainTemplates, + chainRoles, + edgeVocabulary, + allEdges + ); + + // Skip if no matches + if (workbenchModel.matches.length === 0) { + continue; + } + + // Calculate gap counts + const gapCounts = { + missingSlots: 0, + missingLinks: 0, + weakRoles: 0, + candidateCleanup: 0, + }; + + for (const match of workbenchModel.matches) { + for (const todo of match.todos) { + if (todo.type === "missing_slot") gapCounts.missingSlots++; + else if (todo.type === "missing_link") gapCounts.missingLinks++; + else if (todo.type === "weak_roles") gapCounts.weakRoles++; + else if (todo.type === "candidate_cleanup") gapCounts.candidateCleanup++; + } + } + + // Determine overall status (prioritize near_complete) + let overallStatus: "near_complete" | "partial" | "weak" = "partial"; + const hasNearComplete = workbenchModel.matches.some((m) => m.status === "near_complete"); + const hasWeak = workbenchModel.matches.some((m) => m.status === "weak"); + + if (hasNearComplete) { + overallStatus = "near_complete"; + } else if (hasWeak) { + overallStatus = "weak"; + } + + // Add scan item + items.push({ + file: file.path, + heading: section.heading, + matches: workbenchModel.matches.map((m) => ({ + templateName: m.templateName, + status: m.status, + score: m.score, + slotsFilled: m.slotsFilled, + slotsTotal: m.slotsTotal, + satisfiedLinks: m.satisfiedLinks, + requiredLinks: m.requiredLinks, + todosCount: m.todos.length, + })), + gapCounts, + status: overallStatus, + }); + } + } catch (e) { + console.error(`[Vault Triage Scan] Error processing ${file.path}:`, e); + // Continue with next file + } + } + + return items; +} + +/** + * Save scan state to plugin data store. + */ +export async function saveScanState( + app: App, + state: ScanState, + pluginInstance: any +): Promise { + if (pluginInstance && pluginInstance.loadData && pluginInstance.saveData) { + await pluginInstance.saveData({ + vaultTriageScanState: state, + }); + } +} + +/** + * Load scan state from plugin data store. + */ +export async function loadScanState( + app: App, + pluginInstance: any +): Promise { + if (pluginInstance && pluginInstance.loadData) { + const data = await pluginInstance.loadData(); + return data?.vaultTriageScanState || null; + } + return null; +} diff --git a/src/workbench/workbenchBuilder.ts b/src/workbench/workbenchBuilder.ts new file mode 100644 index 0000000..5d8f3ce --- /dev/null +++ b/src/workbench/workbenchBuilder.ts @@ -0,0 +1,108 @@ +/** + * Build workbench model from chain inspector report. + */ + +import type { App } from "obsidian"; +import type { ChainInspectorReport } from "../analysis/chainInspector"; +import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; +import type { EdgeVocabulary } from "../vocab/types"; +import type { IndexedEdge } from "../analysis/graphIndex"; +import type { WorkbenchModel, WorkbenchMatch, WorkbenchTodoUnion } from "./types"; +import { calculateMatchStatus, calculateSlotsStats, hasWeakRoles, getEffectiveRequiredLinks } from "./statusCalculator"; +import { generateTodos, generateFindingsTodos } from "./todoGenerator"; + +/** + * Build workbench model from chain inspector report. + */ +export async function buildWorkbenchModel( + app: App, + report: ChainInspectorReport, + chainTemplates: ChainTemplatesConfig | null, + chainRoles: ChainRolesConfig | null, + edgeVocabulary: EdgeVocabulary | null, + allEdges: IndexedEdge[] +): Promise { + const matches: WorkbenchMatch[] = []; + + if (!report.templateMatches || !chainTemplates) { + return { + context: report.context, + matches: [], + timestamp: Date.now(), + }; + } + + // Get candidate edges from current note + // Also check for confirmed edges that would make candidates "not needed" + const candidateEdges = allEdges.filter((e) => e.scope === "candidate"); + const confirmedEdges = allEdges.filter((e) => e.scope !== "candidate"); + + // Get profile config for required_links resolution + const profileConfig = report.templateMatchingProfileUsed?.profileConfig; + const profileRequiredLinks = profileConfig?.required_links; + + for (const match of report.templateMatches) { + // Find template + const template = chainTemplates.templates.find((t) => t.name === match.templateName); + if (!template) continue; + + // Calculate slots stats + const slotsStats = calculateSlotsStats(match); + + // Check for weak roles + const matchHasWeakRoles = hasWeakRoles(match); + + // Calculate status + const status = calculateMatchStatus(match, matchHasWeakRoles); + + // Get effective required_links + const effectiveRequiredLinks = getEffectiveRequiredLinks( + match, + profileRequiredLinks, + template.matching?.required_links, + chainTemplates.defaults?.matching?.required_links + ); + + // Generate todos + const todos = await generateTodos( + app, + match, + template, + chainRoles, + edgeVocabulary, + allEdges, + candidateEdges, + confirmedEdges, + report.context + ); + + matches.push({ + ...match, + status, + slotsFilled: slotsStats.filled, + slotsTotal: slotsStats.total, + effectiveRequiredLinks, + todos, + }); + } + + // Generate todos from findings + const globalTodos: WorkbenchTodoUnion[] = []; + if (report.findings && report.findings.length > 0) { + const findingsTodos = generateFindingsTodos( + report.findings, + allEdges, + report.context, + chainRoles, + edgeVocabulary + ); + globalTodos.push(...findingsTodos); + } + + return { + context: report.context, + matches, + globalTodos: globalTodos.length > 0 ? globalTodos : undefined, + timestamp: Date.now(), + }; +} diff --git a/src/workbench/writerActions.ts b/src/workbench/writerActions.ts new file mode 100644 index 0000000..63c6902 --- /dev/null +++ b/src/workbench/writerActions.ts @@ -0,0 +1,780 @@ +/** + * Writer actions for workbench todos. + */ + +import { Modal, Setting, TFile, Notice } from "obsidian"; +import type { App, Editor } from "obsidian"; +import type { MissingLinkTodo, CandidateCleanupTodo, MissingSlotTodo } from "./types"; +import { detectZone, findSection } from "./zoneDetector"; +import { splitIntoSections } from "../mapping/sectionParser"; +import { EntityPickerModal } from "../ui/EntityPickerModal"; +import { NoteIndex } from "../entityPicker/noteIndex"; +import { EdgeTypeChooserModal } from "../ui/EdgeTypeChooserModal"; +import type { Vocabulary } from "../vocab/Vocabulary"; +import type { EdgeVocabulary } from "../vocab/types"; +import type { MindnetSettings } from "../settings"; +import { extractExistingMappings } from "../mapping/mappingExtractor"; +import { buildMappingBlock, insertMappingBlock } from "../mapping/mappingBuilder"; + +/** + * Insert edge forward (from source to target). + */ +export async function insertEdgeForward( + app: App, + editor: Editor, + file: TFile, + todo: MissingLinkTodo, + vocabulary: Vocabulary, + edgeVocabulary: EdgeVocabulary, + settings: MindnetSettings, + graphSchema: any = null, + targetZone: "section" | "note_links" | "candidates" = "section" +): Promise { + console.log("[insertEdgeForward] ===== STARTING ====="); + console.log("[insertEdgeForward] targetZone:", targetZone); + console.log("[insertEdgeForward] file:", file.path); + console.log("[insertEdgeForward] todo type:", todo.type); + console.log("[insertEdgeForward] Todo:", todo); + console.log("[insertEdgeForward] Suggested edge types:", todo.suggestedEdgeTypes); + + // Let user choose edge type (direction is always "forward" for insert_edge_forward) + console.log("[insertEdgeForward] Opening edge type chooser..."); + const edgeType = await chooseEdgeType( + app, + edgeVocabulary, + todo.suggestedEdgeTypes, + todo.fromNodeRef.noteType, + todo.toNodeRef.noteType, + graphSchema, + "forward" // insert_edge_forward is always forward direction + ); + console.log("[insertEdgeForward] Selected edge type:", edgeType); + + if (!edgeType) { + console.log("[insertEdgeForward] User cancelled edge type selection"); + return; // User cancelled + } + + // Build target link (extract basename from file path) + const targetBasename = todo.toNodeRef.file.replace(/\.md$/, "").split("/").pop() || todo.toNodeRef.file; + const targetLink = todo.toNodeRef.heading + ? `${targetBasename}#${todo.toNodeRef.heading}` + : targetBasename; + + console.log("[insertEdgeForward] Target link:", targetLink); + console.log("[insertEdgeForward] Inserting into zone:", targetZone); + + // Determine where to insert + if (targetZone === "note_links") { + // Insert in Note-Verbindungen zone + console.log("[insertEdgeForward] Inserting into note_links zone"); + await insertEdgeInZone(app, editor, file, "note_links", edgeType, targetLink, settings); + } else if (targetZone === "candidates") { + // Insert in Kandidaten zone + console.log("[insertEdgeForward] Inserting into candidates zone"); + await insertEdgeInZone(app, editor, file, "candidates", edgeType, targetLink, settings); + } else { + // Insert in source section - need to select section if multiple exist + // IMPORTANT: Use source note (fromNodeRef.file), not current note! + // Try to find source file - it might be full path, basename, or basename without .md + let sourceFile: TFile | null = null; + const fileRef = todo.fromNodeRef.file; + + // Try different formats + const possiblePaths = [ + fileRef, // Full path as-is + fileRef + ".md", // Add .md extension + fileRef.replace(/\.md$/, ""), // Remove .md if present + fileRef.replace(/\.md$/, "") + ".md", // Ensure .md + ]; + + // Also try with current file's directory if it's a relative path + const currentDir = file.path.split("/").slice(0, -1).join("/"); + if (currentDir) { + possiblePaths.push( + `${currentDir}/${fileRef}`, + `${currentDir}/${fileRef}.md`, + `${currentDir}/${fileRef.replace(/\.md$/, "")}.md` + ); + } + + console.log("[insertEdgeForward] Trying to find source file, fileRef:", fileRef); + console.log("[insertEdgeForward] Possible paths:", possiblePaths); + + for (const path of possiblePaths) { + const found = app.vault.getAbstractFileByPath(path); + if (found && found instanceof TFile) { + sourceFile = found; + console.log("[insertEdgeForward] Found source file at path:", path); + break; + } + } + + // If still not found, try resolving as wikilink (like templateMatching does) + if (!sourceFile) { + const basename = fileRef.replace(/\.md$/, "").split("/").pop() || fileRef; + console.log("[insertEdgeForward] Trying to resolve as wikilink, basename:", basename); + const resolved = app.metadataCache.getFirstLinkpathDest(basename, file.path); + if (resolved) { + sourceFile = resolved; + console.log("[insertEdgeForward] Found source file via wikilink resolution:", sourceFile.path); + } + } + + // If still not found, try searching by basename in all files + if (!sourceFile) { + const basename = fileRef.replace(/\.md$/, "").split("/").pop() || fileRef; + console.log("[insertEdgeForward] Searching by basename in all files:", basename); + const allFiles = app.vault.getMarkdownFiles(); + sourceFile = allFiles.find((f) => { + const fBasename = f.basename; + const fPath = f.path; + return fBasename === basename || fPath.endsWith(`/${basename}.md`) || fPath === `${basename}.md`; + }) || null; + + if (sourceFile) { + console.log("[insertEdgeForward] Found source file by basename:", sourceFile.path); + } + } + + if (!sourceFile) { + console.error("[insertEdgeForward] Source file not found. fileRef:", fileRef, "tried paths:", possiblePaths); + new Notice(`Source file not found: ${fileRef}`); + return; + } + + console.log("[insertEdgeForward] Inserting into section, source file:", sourceFile.path, "preferred heading:", todo.fromNodeRef.heading); + const sourceSection = await selectSectionForEdge( + app, + sourceFile, + todo.fromNodeRef.heading + ); + console.log("[insertEdgeForward] Selected section:", sourceSection); + + if (!sourceSection) { + console.log("[insertEdgeForward] User cancelled section selection"); + return; // User cancelled + } + + // Get editor for source file (or create one) + let sourceEditor = editor; + if (sourceFile.path !== file.path) { + // Open source file in editor temporarily + await app.workspace.openLinkText(sourceFile.path, "", false); + const activeEditor = app.workspace.activeEditor?.editor; + if (!activeEditor) { + console.error("[insertEdgeForward] Could not get editor for source file"); + return; + } + sourceEditor = activeEditor; + } + + console.log("[insertEdgeForward] Inserting edge into section"); + await insertEdgeInSection( + app, + sourceEditor, + sourceFile, + sourceSection, + edgeType, + targetLink, + settings + ); + console.log("[insertEdgeForward] Edge insertion completed"); + } +} + +/** + * Insert edge in zone (Kandidaten or Note-Verbindungen). + */ +async function insertEdgeInZone( + app: App, + editor: Editor, + file: TFile, + zoneType: "candidates" | "note_links", + edgeType: string, + targetLink: string, + settings: MindnetSettings +): Promise { + const zone = await detectZone(app, file, zoneType); + + const wrapperCalloutType = settings.mappingWrapperCalloutType || "abstract"; + const wrapperTitle = settings.mappingWrapperTitle || "🕸️ Semantic Mapping"; + const foldMarker = settings.mappingWrapperFolded ? "-" : "+"; + + if (!zone.exists) { + // Create zone + const content = editor.getValue(); + const newZone = `\n\n## ${zoneType === "candidates" ? "Kandidaten" : "Note-Verbindungen"}\n\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + const newContent = content + newZone; + editor.setValue(newContent); + return; + } + + // Insert in existing zone + const content = editor.getValue(); + const lines = content.split(/\r?\n/); + + // Find insertion point (end of zone, before next heading) + let insertLine = zone.endLine - 1; + + // Check if zone has a wrapper callout using settings + const zoneContent = zone.content; + const hasWrapper = zoneContent.includes(`> [!${wrapperCalloutType}]`) && + zoneContent.includes(wrapperTitle); + + if (hasWrapper) { + // Insert inside wrapper - find the end of the wrapper block + const wrapperEndLine = findWrapperBlockEnd(lines, zone.startLine, wrapperCalloutType, wrapperTitle); + if (wrapperEndLine !== null) { + insertLine = wrapperEndLine - 1; + } + const newEdge = `>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + lines.splice(insertLine, 0, ...newEdge.split("\n")); + } else { + // Create wrapper and insert edge + const wrapper = `\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + lines.splice(insertLine, 0, ...wrapper.split("\n")); + } + + editor.setValue(lines.join("\n")); +} + +/** + * Find the end line of a wrapper block. + */ +function findWrapperBlockEnd( + lines: string[], + startLine: number, + calloutType: string, + title: string +): number | null { + const calloutTypeLower = calloutType.toLowerCase(); + const titleLower = title.toLowerCase(); + const calloutHeaderRegex = new RegExp( + `^\\s*>\\s*\\[!${calloutTypeLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]\\s*[+-]?\\s*(.+)$`, + "i" + ); + + let inWrapper = false; + let quoteLevel = 0; + + for (let i = startLine; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + + const trimmed = line.trim(); + const match = line.match(calloutHeaderRegex); + + if (match && match[1]) { + const headerTitle = match[1].trim().toLowerCase(); + if (headerTitle.includes(titleLower) || titleLower.includes(headerTitle)) { + inWrapper = true; + quoteLevel = (line.match(/^\s*(>+)/)?.[1]?.length || 0); + continue; + } + } + + if (inWrapper) { + if (trimmed.match(/^\^map-/)) { + return i + 1; + } + + const currentQuoteLevel = (line.match(/^\s*(>+)/)?.[1]?.length || 0); + if (trimmed !== "" && currentQuoteLevel < quoteLevel) { + return i; + } + + if (!line.startsWith(">") && trimmed.match(/^#{1,6}\s+/)) { + return i; + } + } + } + + return inWrapper ? lines.length : null; +} + +/** + * Select section for edge insertion (if multiple sections exist). + */ +async function selectSectionForEdge( + app: App, + file: TFile, + preferredHeading: string | null +): Promise<{ startLine: number; endLine: number; content: string; heading: string | null } | null> { + console.log("[selectSectionForEdge] Starting, preferredHeading:", preferredHeading); + const content = await app.vault.read(file); + const sections = splitIntoSections(content); + console.log("[selectSectionForEdge] Found", sections.length, "sections"); + console.log("[selectSectionForEdge] Section headings:", sections.map(s => s.heading)); + + // Filter out special zones + const contentSections = sections.filter( + (s) => s.heading !== "Kandidaten" && s.heading !== "Note-Verbindungen" + ); + console.log("[selectSectionForEdge] Content sections after filtering:", contentSections.length); + console.log("[selectSectionForEdge] Content section headings:", contentSections.map(s => s.heading)); + + if (contentSections.length === 0) { + console.log("[selectSectionForEdge] No content sections found"); + return null; + } + + // If only one section, use it (no need to ask) + if (contentSections.length === 1) { + const section = contentSections[0]; + if (!section) return null; + console.log("[selectSectionForEdge] Using single section:", section.heading || "(Root)"); + return { + startLine: section.startLine, + endLine: section.endLine, + content: section.content, + heading: section.heading, + }; + } + + // Multiple sections - always show selection modal + // If preferred heading matches, highlight it or select it by default + // But still show the modal so user can confirm or choose differently + console.log("[selectSectionForEdge] Multiple sections found, showing selection modal"); + console.log("[selectSectionForEdge] Preferred heading:", preferredHeading); + return new Promise((resolve) => { + let resolved = false; + const modal = new Modal(app); + modal.titleEl.textContent = "Select Section"; + const description = preferredHeading + ? `Which section should the edge be inserted into? (Preferred: "${preferredHeading}")` + : "Which section should the edge be inserted into?"; + modal.contentEl.createEl("p", { text: description }); + + const doResolve = (value: { startLine: number; endLine: number; content: string; heading: string | null } | null) => { + if (!resolved) { + resolved = true; + resolve(value); + } + }; + + for (const section of contentSections) { + if (!section) continue; + const sectionName = section.heading || "(Root section)"; + const isPreferred = preferredHeading && section.heading === preferredHeading; + const setting = new Setting(modal.contentEl); + + if (isPreferred) { + setting.setName(`⭐ ${sectionName} (preferred)`); + setting.setDesc(`Lines ${section.startLine + 1}-${section.endLine} - Recommended based on chain template`); + } else { + setting.setName(sectionName); + setting.setDesc(`Lines ${section.startLine + 1}-${section.endLine}`); + } + + setting.addButton((btn) => { + if (isPreferred) { + btn.setButtonText("Select (Recommended)"); + btn.setCta(); // Highlight recommended option + } else { + btn.setButtonText("Select"); + } + btn.onClick(() => { + console.log("[selectSectionForEdge] User selected section:", sectionName); + doResolve({ + startLine: section.startLine, + endLine: section.endLine, + content: section.content, + heading: section.heading, + }); + modal.close(); + }); + }); + } + + modal.onClose = () => { + console.log("[selectSectionForEdge] Modal closed, resolved:", resolved); + if (!resolved) { + console.log("[selectSectionForEdge] Resolving with null (user cancelled)"); + doResolve(null); + } + }; + modal.open(); + }); +} + +/** + * Insert edge in section (section-scope). + */ +async function insertEdgeInSection( + app: App, + editor: Editor, + file: TFile, + section: { startLine: number; endLine: number; content: string }, + edgeType: string, + targetLink: string, + settings: MindnetSettings +): Promise { + const content = editor.getValue(); + const lines = content.split(/\r?\n/); + + // Get wrapper settings + const wrapperCalloutType = settings.mappingWrapperCalloutType || "abstract"; + const wrapperTitle = settings.mappingWrapperTitle || "🕸️ Semantic Mapping"; + const wrapperFolded = settings.mappingWrapperFolded !== false; + + // Extract existing mappings from section + const mappingState = extractExistingMappings(section.content, wrapperCalloutType, wrapperTitle); + + // Check if section has semantic mapping block + const hasMappingBlock = mappingState.wrapperBlockStartLine !== null; + + if (hasMappingBlock && mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) { + console.log("[insertEdgeInSection] Inserting into existing mapping block"); + // Insert edge into existing mapping block + const sectionLines = section.content.split(/\r?\n/); + const wrapperStart = mappingState.wrapperBlockStartLine; + const wrapperEnd = mappingState.wrapperBlockEndLine; + + // Find if edgeType group already exists in wrapper block + const wrapperBlockContent = sectionLines.slice(wrapperStart, wrapperEnd).join("\n"); + console.log("[insertEdgeInSection] Wrapper block content length:", wrapperBlockContent.length); + + const edgeTypeGroupRegex = new RegExp(`(>>\\s*\\[!edge\\]\\s+${edgeType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(?=\\n\\n|\\n>>\\s*\\[!edge\\]|\\n>\\s*\\^map-|$)`); + const edgeTypeMatch = wrapperBlockContent.match(edgeTypeGroupRegex); + console.log("[insertEdgeInSection] Edge type group match:", edgeTypeMatch ? "found" : "not found"); + + if (edgeTypeMatch && edgeTypeMatch[1]) { + console.log("[insertEdgeInSection] Appending to existing edge type group"); + // Edge type group exists - append to it + const newEdge = `>> [[${targetLink}]]\n`; + const updatedGroup = edgeTypeMatch[1].trimEnd() + "\n" + newEdge; + const updatedWrapperBlock = wrapperBlockContent.replace(edgeTypeMatch[0], updatedGroup); + + // Replace wrapper block in section + const beforeWrapper = sectionLines.slice(0, wrapperStart).join("\n"); + const afterWrapper = sectionLines.slice(wrapperEnd).join("\n"); + const newSectionContent = beforeWrapper + "\n" + updatedWrapperBlock + "\n" + afterWrapper; + + // Replace section in full content + const beforeSection = lines.slice(0, section.startLine).join("\n"); + const afterSection = lines.slice(section.endLine).join("\n"); + const newContent = beforeSection + "\n" + newSectionContent + "\n" + afterSection; + console.log("[insertEdgeInSection] Setting new content, length:", newContent.length); + editor.setValue(newContent); + console.log("[insertEdgeInSection] Content updated successfully"); + return; + } else { + console.log("[insertEdgeInSection] Adding new edge type group"); + // Edge type group doesn't exist - add new group before block-id marker or at end + const blockIdMatch = wrapperBlockContent.match(/\n>?\s*\^map-/); + const insertPos = blockIdMatch ? blockIdMatch.index || wrapperBlockContent.length : wrapperBlockContent.length; + console.log("[insertEdgeInSection] Insert position:", insertPos); + + const newEdgeGroup = `\n>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + const updatedWrapperBlock = + wrapperBlockContent.slice(0, insertPos) + + newEdgeGroup + + wrapperBlockContent.slice(insertPos); + + // Replace wrapper block in section + const beforeWrapper = sectionLines.slice(0, wrapperStart).join("\n"); + const afterWrapper = sectionLines.slice(wrapperEnd).join("\n"); + const newSectionContent = beforeWrapper + "\n" + updatedWrapperBlock + "\n" + afterWrapper; + + // Replace section in full content + const beforeSection = lines.slice(0, section.startLine).join("\n"); + const afterSection = lines.slice(section.endLine).join("\n"); + const newContent = beforeSection + "\n" + newSectionContent + "\n" + afterSection; + console.log("[insertEdgeInSection] Setting new content with new edge group, length:", newContent.length); + editor.setValue(newContent); + console.log("[insertEdgeInSection] Content updated successfully"); + return; + } + } + + // Create new mapping block at end of section + console.log("[insertEdgeInSection] Creating new mapping block"); + const existingMappings = new Map(); + const targetLinkBasename = targetLink.split("#")[0]?.split("|")[0]?.trim() || targetLink; + existingMappings.set(targetLinkBasename, edgeType); + console.log("[insertEdgeInSection] Target link basename:", targetLinkBasename); + + const newMappingBlock = buildMappingBlock( + [targetLinkBasename], + existingMappings, + { + wrapperCalloutType, + wrapperTitle, + wrapperFolded, + defaultEdgeType: settings.defaultEdgeType || "", + assignUnmapped: "none", + } + ); + console.log("[insertEdgeInSection] New mapping block:", newMappingBlock ? "created" : "null"); + + if (!newMappingBlock) { + console.log("[insertEdgeInSection] Using fallback mapping block"); + // Fallback + const foldMarker = wrapperFolded ? "-" : "+"; + const mappingBlock = `\n\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + const newSectionContent = section.content.trimEnd() + mappingBlock; + + const beforeSection = lines.slice(0, section.startLine).join("\n"); + const afterSection = lines.slice(section.endLine).join("\n"); + const newContent = beforeSection + "\n" + newSectionContent + "\n" + afterSection; + console.log("[insertEdgeInSection] Setting fallback content, length:", newContent.length); + editor.setValue(newContent); + console.log("[insertEdgeInSection] Fallback content updated"); + return; + } + + const newSectionContent = insertMappingBlock(section.content, newMappingBlock); + console.log("[insertEdgeInSection] New section content length:", newSectionContent.length); + + // Replace section in content + const beforeSection = lines.slice(0, section.startLine).join("\n"); + const afterSection = lines.slice(section.endLine).join("\n"); + const newContent = beforeSection + "\n" + newSectionContent + "\n" + afterSection; + console.log("[insertEdgeInSection] Final content length:", newContent.length); + editor.setValue(newContent); + console.log("[insertEdgeInSection] Content updated successfully"); +} + +/** + * Promote candidate edge to explicit edge. + */ +export async function promoteCandidate( + app: App, + editor: Editor, + file: TFile, + todo: CandidateCleanupTodo, + settings: any +): Promise { + const content = editor.getValue(); + + // Find candidates zone + const candidatesZone = await detectZone(app, file, "candidates"); + if (!candidatesZone.exists) { + throw new Error("Candidates zone not found"); + } + + // Find candidate edge in zone + const candidateEdge = todo.candidateEdge; + const edgeType = candidateEdge.rawEdgeType; + const targetLink = candidateEdge.to.heading + ? `[[${candidateEdge.to.file}#${candidateEdge.to.heading}]]` + : `[[${candidateEdge.to.file}]]`; + + // Determine source section (from evidence or current context) + const sourceSectionHeading = candidateEdge.evidence.sectionHeading; + const sourceSection = await findSection(app, file, sourceSectionHeading); + + if (!sourceSection) { + throw new Error(`Source section not found: ${sourceSectionHeading}`); + } + + // Insert edge in source section + await insertEdgeInSection(app, editor, file, sourceSection, edgeType, targetLink, settings); + + // Remove candidate edge if keepOriginal is false + if (!settings.fixActions?.promoteCandidate?.keepOriginal) { + const lines = content.split(/\r?\n/); + const candidateLineStart = candidateEdge.evidence.lineRange?.start || candidatesZone.startLine; + const candidateLineEnd = candidateEdge.evidence.lineRange?.end || candidatesZone.endLine; + + // Remove candidate edge lines + lines.splice(candidateLineStart, candidateLineEnd - candidateLineStart + 1); + editor.setValue(lines.join("\n")); + } +} + +/** + * Filter suggested edge types by direction and note types. + * + * @param suggestedTypes - Edge types from chain_roles + * @param edgeVocabulary - Edge vocabulary to check inverses + * @param sourceType - Source note type + * @param targetType - Target note type + * @param graphSchema - Graph schema for type compatibility + * @param direction - "forward" for insert_edge_forward, "backward" for insert_edge_inverse + */ +async function filterSuggestedEdgeTypes( + suggestedTypes: string[], + edgeVocabulary: EdgeVocabulary, + sourceType: string | null, + targetType: string | null, + graphSchema: any, + direction: "forward" | "backward" = "forward" +): Promise { + // Step 1: Filter by direction + // For forward: keep only types that are NOT inverses of other suggested types + // For backward: keep only types that ARE inverses of other suggested types, or have no inverse defined + const filteredByDirection: string[] = []; + + if (direction === "forward") { + // Forward: Keep types that are forward-direction (not inverses) + // Strategy: If a type has an inverse that's also in suggestedTypes, prefer the one that's NOT the inverse + const suggestedSet = new Set(suggestedTypes); + const processedPairs = new Set(); + + for (const type of suggestedTypes) { + const entry = edgeVocabulary.byCanonical.get(type); + if (entry?.inverse && suggestedSet.has(entry.inverse)) { + // Both type and its inverse are in the list - we have a pair + // For forward, prefer the type that is NOT the inverse of the other + // Create a canonical pair key (sorted) + const pairKey = [type, entry.inverse].sort().join("|"); + if (!processedPairs.has(pairKey)) { + processedPairs.add(pairKey); + // For forward direction, keep the type that appears first in the list + // (assuming chain_roles lists forward types first) + const typeIndex = suggestedTypes.indexOf(type); + const inverseIndex = suggestedTypes.indexOf(entry.inverse); + if (typeIndex < inverseIndex) { + // Type appears first, keep it for forward + filteredByDirection.push(type); + } else { + // Inverse appears first, keep the inverse for forward (it's actually the forward type) + filteredByDirection.push(entry.inverse); + } + } + } else if (!entry?.inverse) { + // No inverse defined, keep it (bidirectional or forward-only) + filteredByDirection.push(type); + } else if (entry.inverse && !suggestedSet.has(entry.inverse)) { + // Has inverse but inverse is not in suggested list - this is likely forward + filteredByDirection.push(type); + } + } + } else { + // Backward: Keep types that are backward-direction (inverses) + const suggestedSet = new Set(suggestedTypes); + const processedPairs = new Set(); + + for (const type of suggestedTypes) { + const entry = edgeVocabulary.byCanonical.get(type); + if (entry?.inverse && suggestedSet.has(entry.inverse)) { + // Both type and its inverse are in the list + const pairKey = [type, entry.inverse].sort().join("|"); + if (!processedPairs.has(pairKey)) { + processedPairs.add(pairKey); + // For backward direction, keep the type that appears second (the inverse) + const typeIndex = suggestedTypes.indexOf(type); + const inverseIndex = suggestedTypes.indexOf(entry.inverse); + if (typeIndex > inverseIndex) { + // Type appears second, keep it for backward + filteredByDirection.push(type); + } else { + // Inverse appears second, keep the inverse for backward + filteredByDirection.push(entry.inverse); + } + } + } else if (entry?.inverse && !suggestedSet.has(entry.inverse)) { + // Has inverse but inverse is not in suggested list - this might be backward + // Check if this type is the inverse of another suggested type + let isInverseOfSuggested = false; + for (const otherType of suggestedTypes) { + const otherEntry = edgeVocabulary.byCanonical.get(otherType); + if (otherEntry?.inverse === type) { + isInverseOfSuggested = true; + break; + } + } + if (isInverseOfSuggested) { + filteredByDirection.push(type); + } + } else if (!entry?.inverse) { + // No inverse defined, might be bidirectional - include it + filteredByDirection.push(type); + } + } + } + + // Step 2: Filter by note type compatibility using graph schema + if (graphSchema && sourceType && targetType) { + const { computeEdgeSuggestions } = await import("../mapping/schemaHelper"); + const schemaSuggestions = computeEdgeSuggestions( + edgeVocabulary, + sourceType, + targetType, + graphSchema + ); + + // Keep only suggested types that are either: + // - In typical (recommended for this source->target) + // - Not prohibited + // - Or if no schema suggestions, keep all + const finalFiltered: string[] = []; + for (const type of filteredByDirection) { + const isTypical = schemaSuggestions.typical.includes(type); + const isProhibited = schemaSuggestions.prohibited.includes(type); + + if (schemaSuggestions.typical.length > 0) { + // Schema has recommendations: prefer typical, avoid prohibited + if (isTypical || (!isProhibited && filteredByDirection.length <= 3)) { + // Keep if typical, or if not prohibited and we don't have many options + finalFiltered.push(type); + } + } else { + // No schema recommendations: keep all (unless prohibited) + if (!isProhibited) { + finalFiltered.push(type); + } + } + } + + // If we filtered everything out, fall back to direction-filtered list + return finalFiltered.length > 0 ? finalFiltered : filteredByDirection; + } + + // No schema filtering, return direction-filtered + return filteredByDirection.length > 0 ? filteredByDirection : suggestedTypes; +} + +/** + * Choose edge type from suggestions. + */ +async function chooseEdgeType( + app: App, + edgeVocabulary: EdgeVocabulary, + suggestedTypes: string[], + sourceType: string | null = null, + targetType: string | null = null, + graphSchema: any = null, + direction: "forward" | "backward" = "forward" +): Promise { + console.log("[chooseEdgeType] Starting, suggestedTypes:", suggestedTypes); + console.log("[chooseEdgeType] sourceType:", sourceType, "targetType:", targetType, "direction:", direction); + console.log("[chooseEdgeType] edgeVocabulary entries:", edgeVocabulary.byCanonical.size); + + // Filter suggested types by direction and note types + const filteredSuggestedTypes = await filterSuggestedEdgeTypes( + suggestedTypes, + edgeVocabulary, + sourceType, + targetType, + graphSchema, + direction + ); + console.log("[chooseEdgeType] Filtered suggested types:", filteredSuggestedTypes); + + // Show edge type chooser with filtered suggested types + const modal = new EdgeTypeChooserModal( + app, + edgeVocabulary, + sourceType, + targetType, + graphSchema, + filteredSuggestedTypes // Pass filtered suggested types to modal + ); + + console.log("[chooseEdgeType] Modal created, calling show()..."); + const result = await modal.show(); + console.log("[chooseEdgeType] Modal result:", result); + + // Return alias if selected, otherwise canonical + if (!result) { + console.log("[chooseEdgeType] No result, returning null"); + return null; + } + + const selectedType = result.alias || result.edgeType; + console.log("[chooseEdgeType] Returning selected type:", selectedType); + return selectedType; +} diff --git a/src/workbench/zoneDetector.ts b/src/workbench/zoneDetector.ts new file mode 100644 index 0000000..5d3be3c --- /dev/null +++ b/src/workbench/zoneDetector.ts @@ -0,0 +1,160 @@ +/** + * Zone detection for writer operations. + */ + +import type { App, TFile } from "obsidian"; + +export interface ZoneInfo { + exists: boolean; + heading: string; + startLine: number; + endLine: number; + content: string; +} + +/** + * Detect if a zone exists in a file. + */ +export async function detectZone( + app: App, + file: TFile, + zoneType: "candidates" | "note_links" +): Promise { + const content = await app.vault.read(file); + const lines = content.split(/\r?\n/); + + const heading = zoneType === "candidates" ? "## Kandidaten" : "## Note-Verbindungen"; + + let startLine = -1; + let endLine = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + // Check for exact heading match + if (line.trim() === heading) { + startLine = i; + // Find end of section (next H2 or end of file) + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j]; + if (!nextLine) continue; + if (nextLine.match(/^##\s+/)) { + endLine = j; + break; + } + } + if (endLine === -1) { + endLine = lines.length; + } + break; + } + } + + if (startLine === -1) { + return { + exists: false, + heading, + startLine: -1, + endLine: -1, + content: "", + }; + } + + const zoneContent = lines.slice(startLine, endLine).join("\n"); + + return { + exists: true, + heading, + startLine, + endLine, + content: zoneContent, + }; +} + +/** + * Find section by heading in file. + */ +export async function findSection( + app: App, + file: TFile, + heading: string | null +): Promise { + if (heading === null) { + // Root section (before first heading) + const content = await app.vault.read(file); + const lines = content.split(/\r?\n/); + + // Find first heading + let firstHeadingLine = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + if (line.match(/^##\s+/)) { + firstHeadingLine = i; + break; + } + } + + if (firstHeadingLine === -1) { + firstHeadingLine = lines.length; + } + + return { + exists: true, + heading: "", + startLine: 0, + endLine: firstHeadingLine, + content: lines.slice(0, firstHeadingLine).join("\n"), + }; + } + + const content = await app.vault.read(file); + const lines = content.split(/\r?\n/); + + let startLine = -1; + let endLine = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + // Check for heading match (exact or with different levels) + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch && headingMatch[2]?.trim() === heading) { + startLine = i; + // Find end of section (next heading of same or higher level) + const headingLevel = headingMatch[1]?.length || 0; + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j]; + if (!nextLine) continue; + const nextHeadingMatch = nextLine.match(/^(#{1,6})\s+(.+)$/); + if (nextHeadingMatch) { + const nextLevel = nextHeadingMatch[1]?.length || 0; + if (nextLevel > 0 && nextLevel <= headingLevel) { + endLine = j; + break; + } + } + } + if (endLine === -1) { + endLine = lines.length; + } + break; + } + } + + if (startLine === -1) { + return null; + } + + const sectionContent = lines.slice(startLine, endLine).join("\n"); + + return { + exists: true, + heading, + startLine, + endLine, + content: sectionContent, + }; +}