Implement Chain Workbench and Vault Triage features in Mindnet plugin
- Introduced new workflows for Chain Workbench and Vault Triage, enhancing user capabilities for managing template matches and identifying chain gaps. - Added commands for opening the Chain Workbench and scanning the vault for chain gaps, improving the overall functionality of the plugin. - Updated documentation to include detailed instructions for the new workflows, ensuring users can effectively utilize the features. - Enhanced the UI for both the Chain Workbench and Vault Triage Scan, providing a more intuitive user experience. - Implemented tests for the new functionalities to ensure reliability and accuracy in various scenarios.
This commit is contained in:
parent
327ff4c9c7
commit
a9b3e2f0e2
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
393
docs/02_concepts/03_chain_identification_and_matching.md
Normal file
393
docs/02_concepts/03_chain_identification_and_matching.md
Normal 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.
|
||||||
|
|
@ -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**
|
||||||
|
|
||||||
|
|
|
||||||
309
docs/08_Testing_Chain_Workbench.md
Normal file
309
docs/08_Testing_Chain_Workbench.md
Normal 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.
|
||||||
278
docs/09_Workbench_Analysis_Basis.md
Normal file
278
docs/09_Workbench_Analysis_Basis.md
Normal 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
|
||||||
342
docs/10_Workbench_Findings_Integration.md
Normal file
342
docs/10_Workbench_Findings_Integration.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
112
src/commands/chainWorkbenchCommand.ts
Normal file
112
src/commands/chainWorkbenchCommand.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/commands/vaultTriageScanCommand.ts
Normal file
50
src/commands/vaultTriageScanCommand.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/main.ts
71
src/main.ts
|
|
@ -587,6 +587,77 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "mindnet-chain-workbench",
|
||||||
|
name: "Mindnet: Chain Workbench (Current Section)",
|
||||||
|
editorCallback: async (editor) => {
|
||||||
|
try {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new Notice("No active file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFile.extension !== "md") {
|
||||||
|
new Notice("Active file is not a markdown file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure chain roles and templates are loaded
|
||||||
|
await this.ensureChainRolesLoaded();
|
||||||
|
const chainRoles = this.chainRoles.data;
|
||||||
|
await this.ensureChainTemplatesLoaded();
|
||||||
|
const chainTemplates = this.chainTemplates.data;
|
||||||
|
const templatesLoadResult = this.chainTemplates.result;
|
||||||
|
|
||||||
|
const { executeChainWorkbench } = await import("./commands/chainWorkbenchCommand");
|
||||||
|
await executeChainWorkbench(
|
||||||
|
this.app,
|
||||||
|
editor,
|
||||||
|
activeFile.path,
|
||||||
|
chainRoles,
|
||||||
|
chainTemplates,
|
||||||
|
templatesLoadResult || undefined,
|
||||||
|
this.settings,
|
||||||
|
this
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
new Notice(`Failed to open chain workbench: ${msg}`);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "mindnet-scan-chain-gaps",
|
||||||
|
name: "Mindnet: Scan Vault for Chain Gaps",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
// Ensure chain roles and templates are loaded
|
||||||
|
await this.ensureChainRolesLoaded();
|
||||||
|
const chainRoles = this.chainRoles.data;
|
||||||
|
await this.ensureChainTemplatesLoaded();
|
||||||
|
const chainTemplates = this.chainTemplates.data;
|
||||||
|
const templatesLoadResult = this.chainTemplates.result;
|
||||||
|
|
||||||
|
const { executeVaultTriageScan } = await import("./commands/vaultTriageScanCommand");
|
||||||
|
await executeVaultTriageScan(
|
||||||
|
this.app,
|
||||||
|
chainRoles,
|
||||||
|
chainTemplates,
|
||||||
|
templatesLoadResult || undefined,
|
||||||
|
this.settings,
|
||||||
|
this
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
new Notice(`Failed to start vault triage scan: ${msg}`);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
this.addCommand({
|
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)",
|
||||||
|
|
|
||||||
324
src/tests/workbench/statusCalculator.test.ts
Normal file
324
src/tests/workbench/statusCalculator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
401
src/tests/workbench/todoGenerator.test.ts
Normal file
401
src/tests/workbench/todoGenerator.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
210
src/tests/workbench/zoneDetector.test.ts
Normal file
210
src/tests/workbench/zoneDetector.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
678
src/ui/ChainWorkbenchModal.ts
Normal file
678
src/ui/ChainWorkbenchModal.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,20 +60,21 @@ 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 (suggestions.typical.length > 0) {
|
if (this.suggestedTypes.length > 0) {
|
||||||
contentEl.createEl("h3", { text: "⭐ Recommended (schema)" });
|
contentEl.createEl("h3", { text: "🎯 Suggested (from chain)" });
|
||||||
const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" });
|
const suggestedContainer = contentEl.createEl("div", { cls: "edge-type-list" });
|
||||||
|
|
||||||
for (const canonical of suggestions.typical) {
|
for (const canonical of this.suggestedTypes) {
|
||||||
const entry = this.vocabulary.byCanonical.get(canonical);
|
const entry = this.vocabulary.byCanonical.get(canonical);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
// Display: canonical type (primary), aliases shown after selection
|
// Display: canonical type (primary), aliases shown after selection
|
||||||
const btn = recommendedContainer.createEl("button", {
|
const btn = suggestedContainer.createEl("button", {
|
||||||
text: `⭐ ${canonical}`,
|
text: `🎯 ${canonical}`,
|
||||||
cls: "mod-cta",
|
cls: "mod-cta",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,6 +90,36 @@ export class EdgeTypeChooserModal extends Modal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PRIORITY 2: Recommended section (if any, and not already in suggested)
|
||||||
|
if (suggestions.typical.length > 0) {
|
||||||
|
const notInSuggested = suggestions.typical.filter((t) => !this.suggestedTypes.includes(t));
|
||||||
|
if (notInSuggested.length > 0) {
|
||||||
|
contentEl.createEl("h3", { text: "⭐ Recommended (schema)" });
|
||||||
|
const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" });
|
||||||
|
|
||||||
|
for (const canonical of notInSuggested) {
|
||||||
|
const entry = this.vocabulary.byCanonical.get(canonical);
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
|
// Display: canonical type (primary), aliases shown after selection
|
||||||
|
const btn = recommendedContainer.createEl("button", {
|
||||||
|
text: `⭐ ${canonical}`,
|
||||||
|
cls: "mod-cta",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add description as tooltip/hover text
|
||||||
|
if (entry.description) {
|
||||||
|
btn.title = entry.description;
|
||||||
|
btn.setAttribute("data-description", entry.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.onclick = () => {
|
||||||
|
this.selectEdgeType(canonical, entry.aliases);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// All categories
|
// All categories
|
||||||
if (grouped.size > 1 || (grouped.size === 1 && !grouped.has("All"))) {
|
if (grouped.size > 1 || (grouped.size === 1 && !grouped.has("All"))) {
|
||||||
contentEl.createEl("h3", { text: "📂 All categories" });
|
contentEl.createEl("h3", { text: "📂 All categories" });
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
362
src/ui/VaultTriageScanModal.ts
Normal file
362
src/ui/VaultTriageScanModal.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/workbench/interviewOrchestration.ts
Normal file
369
src/workbench/interviewOrchestration.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/workbench/linkExistingAction.ts
Normal file
78
src/workbench/linkExistingAction.ts
Normal 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.`);
|
||||||
|
}
|
||||||
99
src/workbench/statusCalculator.ts
Normal file
99
src/workbench/statusCalculator.ts
Normal 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;
|
||||||
|
}
|
||||||
491
src/workbench/todoGenerator.ts
Normal file
491
src/workbench/todoGenerator.ts
Normal 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
233
src/workbench/types.ts
Normal 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;
|
||||||
|
}
|
||||||
245
src/workbench/vaultTriageScan.ts
Normal file
245
src/workbench/vaultTriageScan.ts
Normal 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;
|
||||||
|
}
|
||||||
108
src/workbench/workbenchBuilder.ts
Normal file
108
src/workbench/workbenchBuilder.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
780
src/workbench/writerActions.ts
Normal file
780
src/workbench/writerActions.ts
Normal 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;
|
||||||
|
}
|
||||||
160
src/workbench/zoneDetector.ts
Normal file
160
src/workbench/zoneDetector.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user