Implement Chain Workbench and Vault Triage features in Mindnet plugin
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- 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.
This commit is contained in:
Lars 2026-01-26 10:51:12 +01:00
parent 327ff4c9c7
commit a9b3e2f0e2
25 changed files with 6202 additions and 12 deletions

View File

@ -264,6 +264,54 @@ Inhalt mit Links: [[Note1]] und [[Note2]]
- Verfügbare Actions auswählen - Verfügbare Actions auswählen
- Automatische Behebung - 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 ### Workflow 3: Unresolved Link → Note erstellen
1. **Link erstellen:** 1. **Link erstellen:**
@ -323,6 +371,8 @@ Inhalt mit Links: [[Note1]] und [[Note2]]
| Command | Beschreibung | Wann verwenden | | Command | Beschreibung | Wann verwenden |
|---------|--------------|----------------| |---------|--------------|----------------|
| **Mindnet: Inspect Chains (Current Section)** | Analysiert kausale Ketten | Chain-Analyse durchführen | | **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: Fix Findings (Current Section)** | Behebt Findings automatisch | Findings automatisch beheben |
| **Mindnet: Validate current note** | Validiert aktuelle Note (Lint) | Note auf Fehler prüfen | | **Mindnet: Validate current note** | Validiert aktuelle Note (Lint) | Note auf Fehler prüfen |

View File

@ -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<string, CandidateNode>, 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.

View File

@ -151,9 +151,9 @@ npm run lint
### Development Workflow ### Development Workflow
1. **Code ändern** in `src/` 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 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) 5. **Obsidian Plugin reload** (disable/enable)
6. **Testen** 6. **Testen**

View File

@ -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.

View File

@ -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_<slotId>` - 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

View File

@ -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<WorkbenchModel> {
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

View File

@ -106,6 +106,7 @@ Diese Dokumentation deckt alle Aspekte des **Mindnet Causal Assistant** Plugins
### Konzepte & Details ### Konzepte & Details
- [02_causal_chain_retrieving.md](./02_causal_chain_retrieving.md) - Kausale Ketten-Retrieval - [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 - [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 - [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 - [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 - [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 ### Legacy-Dokumentation
- [readme.md](./readme.md) - MVP 1.0 Quickstart - [readme.md](./readme.md) - MVP 1.0 Quickstart

View File

@ -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<ChainTemplatesConfig> | undefined,
settings: MindnetSettings,
pluginInstance: any
): Promise<void> {
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}`);
}
}

View File

@ -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<ChainTemplatesConfig> | undefined,
settings: MindnetSettings,
pluginInstance: any
): Promise<void> {
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}`);
}
}

View File

@ -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({ this.addCommand({
id: "mindnet-build-semantic-mappings", id: "mindnet-build-semantic-mappings",
name: "Mindnet: Build semantic mapping blocks (by section)", name: "Mindnet: Build semantic mapping blocks (by section)",

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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");
});
});

View File

@ -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<string, string> = {
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<void> {
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<void> {
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<void> {
// 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();
}
}

View File

@ -20,19 +20,22 @@ export class EdgeTypeChooserModal extends Modal {
private targetType: string | null; private targetType: string | null;
private graphSchema: GraphSchema | null; private graphSchema: GraphSchema | null;
private selectedCanonical: string | null = null; private selectedCanonical: string | null = null;
private suggestedTypes: string[]; // Suggested edge types from chain_roles
constructor( constructor(
app: any, app: any,
vocabulary: EdgeVocabulary, vocabulary: EdgeVocabulary,
sourceType: string | null, sourceType: string | null,
targetType: string | null, targetType: string | null,
graphSchema: GraphSchema | null = null graphSchema: GraphSchema | null = null,
suggestedTypes: string[] = []
) { ) {
super(app); super(app);
this.vocabulary = vocabulary; this.vocabulary = vocabulary;
this.sourceType = sourceType; this.sourceType = sourceType;
this.targetType = targetType; this.targetType = targetType;
this.graphSchema = graphSchema; this.graphSchema = graphSchema;
this.suggestedTypes = suggestedTypes;
} }
onOpen(): void { onOpen(): void {
@ -57,14 +60,44 @@ export class EdgeTypeChooserModal extends Modal {
totalEdgeTypes: allEdgeTypes.length, totalEdgeTypes: allEdgeTypes.length,
categories: Array.from(grouped.keys()), categories: Array.from(grouped.keys()),
categorySizes: Array.from(grouped.entries()).map(([cat, types]) => ({ category: cat, count: types.length })), categorySizes: Array.from(grouped.entries()).map(([cat, types]) => ({ category: cat, count: types.length })),
suggestedTypes: this.suggestedTypes.length,
}); });
// Recommended section (if any) // 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 this.suggestedTypes) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (!entry) continue;
// Display: canonical type (primary), aliases shown after selection
const btn = suggestedContainer.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);
};
}
}
// PRIORITY 2: Recommended section (if any, and not already in suggested)
if (suggestions.typical.length > 0) { 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)" }); contentEl.createEl("h3", { text: "⭐ Recommended (schema)" });
const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" }); const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" });
for (const canonical of suggestions.typical) { for (const canonical of notInSuggested) {
const entry = this.vocabulary.byCanonical.get(canonical); const entry = this.vocabulary.byCanonical.get(canonical);
if (!entry) continue; if (!entry) continue;
@ -85,6 +118,7 @@ export class EdgeTypeChooserModal extends Modal {
}; };
} }
} }
}
// All categories // All categories
if (grouped.size > 1 || (grouped.size === 1 && !grouped.has("All"))) { if (grouped.size > 1 || (grouped.size === 1 && !grouped.has("All"))) {
@ -105,11 +139,14 @@ export class EdgeTypeChooserModal extends Modal {
const container = contentEl.createEl("div", { cls: "edge-type-list" }); const container = contentEl.createEl("div", { cls: "edge-type-list" });
for (const { canonical, aliases, displayName, description } of types) { 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 isProhibited = suggestions.prohibited.includes(canonical);
const isTypical = suggestions.typical.includes(canonical);
let prefix = "→"; let prefix = "→";
if (isTypical) prefix = "⭐";
if (isProhibited) prefix = "🚫"; if (isProhibited) prefix = "🚫";
// Display: canonical type (primary), aliases shown after selection // Display: canonical type (primary), aliases shown after selection

View File

@ -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<void> {
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();
}
}

View File

@ -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<void> {
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<void> {
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
);
}
}

View File

@ -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<void> {
// 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.`);
}

View File

@ -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;
}

View File

@ -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<WorkbenchTodoUnion[]> {
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;
}

233
src/workbench/types.ts Normal file
View File

@ -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;
}

View File

@ -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<ScanItem[]> {
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<void> {
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<ScanState | null> {
if (pluginInstance && pluginInstance.loadData) {
const data = await pluginInstance.loadData();
return data?.vaultTriageScanState || null;
}
return null;
}

View File

@ -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<WorkbenchModel> {
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(),
};
}

View File

@ -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<void> {
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<void> {
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<void> {
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<string, string>();
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<void> {
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<string[]> {
// 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<string>();
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<string>();
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<string | null> {
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;
}

View File

@ -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<ZoneInfo> {
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<ZoneInfo | null> {
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,
};
}