feat: ontology import, project recovery, graphiti improvements, and docs

- Add ontology import from file (Step 1)
- Persist active task_id to project.json for browser-refresh recovery
- Add project list endpoint and frontend recovery UI
- Improve Graphiti backend stability and oasis profile generation
- Add TechnicalDesign.md and enterprise roadmap spec
- Update .gitignore to exclude secrets and local-only docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-26 14:28:14 +00:00
parent f854a0de12
commit d09ee23cd3
17 changed files with 1454 additions and 110 deletions

4
.gitignore vendored
View File

@ -63,3 +63,7 @@ data/
# Configuració Azure amb secrets (no comitejar mai)
azure/config.sh
# Fitxers locals de treball (no per al repo)
exports.txt
docs/2026-04-26-enterprise-roadmap.md

146
TechnicalDesign.md Normal file
View File

@ -0,0 +1,146 @@
# Technical Design — MiroFish
## Graph Backend
MiroFish suporta dos backends de knowledge graph, seleccionable via `GRAPH_BACKEND` al `.env`:
| Valor | Backend | Requisits |
|-------|---------|-----------|
| `zep` (per defecte) | Zep Cloud (gestionat) | `ZEP_API_KEY` |
| `graphiti` | Graphiti + Neo4j (self-hosted) | `NEO4J_PASSWORD` + variables LLM |
La selecció es fa via la factoria `backend/app/graph/factory.py` — un singleton que instancia `ZepBackend` o `GraphitiBackend` en funció de `GRAPH_BACKEND`. La validació de configuració és condicionada: si `GRAPH_BACKEND=graphiti`, `ZEP_API_KEY` no és necessari i viceversa.
### Commutació entre backends
Només cal canviar al `.env`:
```env
# Per usar Zep Cloud:
GRAPH_BACKEND=zep
ZEP_API_KEY=z_...
# Per usar Graphiti + Neo4j:
GRAPH_BACKEND=graphiti
NEO4J_URI=bolt://<host>:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=<contrasenya>
```
---
## Models LLM
El projecte usa fins a quatre grups de variables LLM, cadascun per a un ús diferent. Totes les variables `LLM_SMALL_*` i `LLM_EMBED_*` fan **fallback** als valors `LLM_*` si no s'estableixen.
### Variables de configuració
```env
# ── Model principal (generatiu, potent) ──────────────────────────────────────
LLM_API_KEY=...
LLM_BASE_URL=https://<recurs>.cognitiveservices.azure.com/openai/deployments/<model>/chat/completions?api-version=2024-05-01-preview
LLM_MODEL_NAME=gpt-5.4
# ── Model petit/ràpid (lightweight, econòmic) ────────────────────────────────
# Fallback a LLM_* si no definit
LLM_SMALL_API_KEY=...
LLM_SMALL_BASE_URL=https://<recurs>.cognitiveservices.azure.com/openai/deployments/<small-model>/chat/completions?api-version=2024-05-01-preview
LLM_SMALL_MODEL_NAME=gpt-5-mini
# ── Model d'embedding (vectorització) ────────────────────────────────────────
# Fallback a LLM_* si no definit. Requerit per Graphiti.
LLM_EMBED_API_KEY=...
LLM_EMBED_BASE_URL=https://<recurs>.services.ai.azure.com/openai/deployments/<embed-model>/embeddings?api-version=2024-05-01-preview
LLM_EMBED_MODEL_NAME=text-embedding-3-large
# ── Model boost (simulació OASIS, opcional) ──────────────────────────────────
# Fallback a LLM_* si no definit
LLM_BOOST_API_KEY=...
LLM_BOOST_BASE_URL=...
LLM_BOOST_MODEL_NAME=gpt-5.4
```
### Mapa d'usos per operació
| Grup de variables | Component | Operació |
|---|---|---|
| `LLM_*` | `OntologyGenerator` | Pas 1 — Anàlisi del document i generació d'ontologia (tipus d'entitat i relació) |
| `LLM_*` | `GraphBuilderService` (mode Zep) | Pas 2 — Extracció d'entitats i relacions del text via Zep SDK |
| `LLM_*` | Graphiti `OpenAIClient` (mode graphiti) | Pas 2 — Extracció d'entitats i relacions del text via graphiti-core |
| `LLM_*` | `OasisProfileGenerator` | Pas 2 — Generació de perfils d'agents OASIS a partir del graf |
| `LLM_*` | `ReportAgent` | Pas 4 — Generació de l'informe analític final (multi-turn, tool use) |
| `LLM_SMALL_*` | Graphiti `OpenAIRerankerClient` | Pas 2 — Reranking de resultats de cerca al graf (mode graphiti) |
| `LLM_SMALL_*` | Graphiti `ModelSize.small` | Pas 2 — Tasques lleugeres internes de graphiti (extracció simplificada) |
| `LLM_EMBED_*` | Graphiti `OpenAIEmbedder` | Pas 2 — Generació de vectors d'embedding per a indexació i cerca semàntica a Neo4j (mode graphiti) |
| `LLM_BOOST_*` | `SimulationRunner` / `run_parallel_simulation.py` | Pas 3 — Decisions d'acció de cada agent durant la simulació OASIS |
### API endpoint usada per cada component
Tots els components del projecte usen **`chat.completions`** o **`embeddings`** — mai `responses` (beta).
| Component | API endpoint | Nota |
|---|---|---|
| `LLMClient` (wrapper projecte) | `chat.completions.create` | Síncrona (`OpenAI`) |
| `OntologyGenerator` | `chat.completions.create` | Via `LLMClient` |
| `OasisProfileGenerator` | `chat.completions.create` | Client intern |
| `SimulationConfigGenerator` | `chat.completions.create` | Client intern |
| `ReportAgent` | `chat.completions.create` | Via `LLMClient` |
| Graphiti `OpenAIGenericClient` | `chat.completions.create` | AsyncOpenAI, injectable |
| Graphiti `OpenAIRerankerClient` | `chat.completions.create` | Amb `logprobs=True` per scoring |
| Graphiti `OpenAIEmbedder` | `embeddings.create` | AsyncOpenAI, injectable |
| OASIS/CAMEL-AI | `chat.completions.create` | Via `ModelFactory` (CAMEL abstraction) |
> **Nota:** graphiti-core inclou també un `OpenAIClient` que usa `responses.parse` (API beta d'OpenAI). MiroFish **no l'usa** — configura `OpenAIGenericClient` que sempre usa `chat.completions`, compatible amb Azure i qualsevol API OpenAI-compatible.
### Notes sobre Azure OpenAI
- `LLM_BASE_URL` accepta la URL completa d'Azure (`/chat/completions?api-version=...`). El codi la processa automàticament: extreu el `api-version` com a `default_query` i retalla el sufix per al SDK.
- El mateix tractament s'aplica a `LLM_EMBED_BASE_URL` (sufix `/embeddings?api-version=...`).
- `LLM_SMALL_BASE_URL` accepta directament la URL base d'Azure AI Foundry (`services.ai.azure.com/api/projects/<proj>/openai/v1/`) sense sufix ni `api-version`.
- `LLM_EMBED_BASE_URL` pot usar el domini `services.ai.azure.com` o `cognitiveservices.azure.com`.
### Recomanació de models (Azure OpenAI)
| Grup | Model recomanat | Motiu |
|------|----------------|-------|
| `LLM_*` | `gpt-5.4` | Raonament complex: ontologia, extracció de graf, informes |
| `LLM_SMALL_*` | `gpt-5-mini` | Tasques lleugeres i econòmiques: reranking, classificació |
| `LLM_EMBED_*` | `text-embedding-3-large` | Màxima qualitat d'embedding semàntic |
| `LLM_BOOST_*` | `gpt-5.4` o `gpt-5-mini` | Simulació: moltes crides curtes, prioritzar velocitat/cost |
---
## Pipeline de 5 passos
```
Pas 1 — Graph Build (ontologia)
└─ OntologyGenerator → LLM_*
Pas 2 — Graph Build (construcció)
├─ mode zep: GraphBuilderService + Zep SDK → LLM_*
└─ mode graphiti: GraphitiBackend
├─ extracció: OpenAIGenericClient → LLM_* (chat.completions)
├─ reranking: OpenAIRerankerClient → LLM_SMALL_*
└─ embedding: OpenAIEmbedder → LLM_EMBED_*
Pas 3 — Simulació OASIS
└─ SimulationRunner / run_parallel_simulation.py → LLM_BOOST_* (o LLM_*)
Pas 4 — Informe
└─ ReportAgent (multi-turn + tool use) → LLM_*
Pas 5 — Interacció live
└─ Chat amb agents simulats → LLM_*
```
---
## Internacionalització (i18n)
- Fitxers de traducció: `/locales/{ca,en,es,zh}.json` — compartits per frontend i backend.
- Instruccions de llengua per al LLM: `/locales/languages.json` (clau `llmInstruction`).
- El frontend injecta el locale actual via header `Accept-Language` a cada petició API.
- El backend detecta el locale a `backend/app/utils/locale.py:get_locale()` i l'usa per:
- Traduccions de missatges d'error (`t()`)
- Instruccions d'idioma als prompts LLM (`get_language_instruction()`)
- L'ontologia generada (descripcions, exemples, `analysis_summary`) sortirà en l'idioma de la UI. Els **noms** de tipus d'entitat i relació seguiran PascalCase/UPPER\_SNAKE\_CASE en l'idioma de la UI (p.ex. `AgenciaGovern`, `TREBALLA_PER` en català).

View File

@ -4,6 +4,7 @@ Uses project context mechanism with server-side persistent state
"""
import os
import json
import traceback
import threading
from flask import request, jsonify
@ -255,6 +256,126 @@ def generate_ontology():
}), 500
# ============== Endpoint 1b: Import ontology ==============
@graph_bp.route('/ontology/import', methods=['POST'])
def import_ontology():
"""
Endpoint 1b: Upload files and import a pre-existing ontology definition
Request method: multipart/form-data
Parameters:
files: Uploaded files (PDF/MD/TXT), multiple allowed
simulation_requirement: Simulation requirement description (required)
ontology: JSON string with entity_types and edge_types (required)
project_name: Project name (optional)
Returns same structure as generate_ontology.
"""
try:
logger.info("=== Starting ontology import ===")
simulation_requirement = request.form.get('simulation_requirement', '')
project_name = request.form.get('project_name', 'Unnamed Project')
ontology_json = request.form.get('ontology', '')
if not simulation_requirement:
return jsonify({
"success": False,
"error": t('api.requireSimulationRequirement')
}), 400
if not ontology_json:
return jsonify({
"success": False,
"error": t('api.requireOntologyJson')
}), 400
try:
ontology = json.loads(ontology_json)
except (ValueError, TypeError):
return jsonify({
"success": False,
"error": t('api.invalidOntologyJson')
}), 400
if not isinstance(ontology.get('entity_types'), list) or not isinstance(ontology.get('edge_types'), list):
return jsonify({
"success": False,
"error": t('api.invalidOntologyStructure')
}), 400
uploaded_files = request.files.getlist('files')
if not uploaded_files or all(not f.filename for f in uploaded_files):
return jsonify({
"success": False,
"error": t('api.requireFileUpload')
}), 400
project = ProjectManager.create_project(name=project_name)
project.simulation_requirement = simulation_requirement
logger.info(f"Project created for import: {project.project_id}")
document_texts = []
all_text = ""
for file in uploaded_files:
if file and file.filename and allowed_file(file.filename):
file_info = ProjectManager.save_file_to_project(
project.project_id,
file,
file.filename
)
project.files.append({
"filename": file_info["original_filename"],
"size": file_info["size"]
})
text = FileParser.extract_text(file_info["path"])
text = TextProcessor.preprocess_text(text)
document_texts.append(text)
all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}"
if not document_texts:
ProjectManager.delete_project(project.project_id)
return jsonify({
"success": False,
"error": t('api.noDocProcessed')
}), 400
project.total_text_length = len(all_text)
ProjectManager.save_extracted_text(project.project_id, all_text)
project.ontology = {
"entity_types": ontology.get("entity_types", []),
"edge_types": ontology.get("edge_types", [])
}
project.analysis_summary = ontology.get("analysis_summary", "")
project.status = ProjectStatus.ONTOLOGY_GENERATED
ProjectManager.save_project(project)
logger.info(f"=== Ontology import complete === Project ID: {project.project_id}")
return jsonify({
"success": True,
"data": {
"project_id": project.project_id,
"project_name": project.name,
"ontology": project.ontology,
"analysis_summary": project.analysis_summary,
"files": project.files,
"total_text_length": project.total_text_length
}
})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ============== Endpoint 2: Build graph ==============
@graph_bp.route('/build', methods=['POST'])

View File

@ -1397,7 +1397,7 @@ def generate_profiles():
"error": t('api.noMatchingEntities')
}), 400
generator = OasisProfileGenerator()
generator = OasisProfileGenerator(graph_id=graph_id)
profiles = generator.generate_profiles_from_entities(
entities=filtered.entities,
use_llm=use_llm

View File

@ -196,22 +196,51 @@ class GraphitiBackend(GraphBackend):
@staticmethod
def _patch_extract_entity_attributes() -> None:
"""Monkey-patch graphiti's _extract_entity_attributes to sanitize LLM output.
"""Monkey-patch graphiti internals to fix two LLM quirks:
Some LLMs return attribute values as nested dicts ({"value": "CTTI"}) instead
of plain strings. Neo4j rejects these with TypeError. We intercept the raw
llm_response dict before it is stored in node.attributes and flatten it.
1. _extract_entity_attributes: some LLMs wrap attribute values in nested
dicts ({"value": "CTTI"}). Neo4j rejects these flatten them.
2. _extract_nodes_single: some LLMs omit entity_type_id from extracted
entities, causing a Pydantic ValidationError. Default missing IDs to 0
(the generic "Entity" type) before validation runs.
"""
import graphiti_core.utils.maintenance.node_operations as _node_ops
original = _node_ops._extract_entity_attributes
# --- patch 1: attribute flattening ---
original_attrs = _node_ops._extract_entity_attributes
async def _patched(llm_client, node, episode, previous_episodes, entity_type):
result = await original(llm_client, node, episode, previous_episodes, entity_type)
# result is a dict — flatten any dict-valued attributes
async def _patched_attrs(llm_client, node, episode, previous_episodes, entity_type):
result = await original_attrs(llm_client, node, episode, previous_episodes, entity_type)
return _flatten_attributes(result) if result else result
_node_ops._extract_entity_attributes = _patched
_node_ops._extract_entity_attributes = _patched_attrs
# --- patch 2: entity_type_id defaulting ---
original_nodes = _node_ops._extract_nodes_single
async def _patched_nodes(llm_client, episode, context):
from graphiti_core.utils.maintenance.node_operations import ExtractedEntities
# Call the LLM the normal way but catch the Pydantic validation error
# that arises when the LLM forgets entity_type_id.
try:
return await original_nodes(llm_client, episode, context)
except Exception as exc:
# Only intercept Pydantic validation errors about entity_type_id
if "entity_type_id" not in str(exc):
raise
logger.warning(f"LLM omitted entity_type_id — defaulting to 0 and retrying validation: {exc}")
# Re-run the LLM call via the internal helper to get the raw dict
from graphiti_core.utils.maintenance.node_operations import _call_extraction_llm
llm_response = await _call_extraction_llm(llm_client, episode, context)
# Inject entity_type_id=0 for any entity that is missing it
entities = llm_response.get("extracted_entities", [])
for ent in entities:
if isinstance(ent, dict) and "entity_type_id" not in ent:
ent["entity_type_id"] = 0
response_object = ExtractedEntities(**llm_response)
return response_object.extracted_entities
_node_ops._extract_nodes_single = _patched_nodes
def create_graph(self, graph_id: str, name: str, description: str = "") -> None:
logger.info(f"Graphiti graph namespace ready: {graph_id}")
@ -428,20 +457,46 @@ class GraphitiBackend(GraphBackend):
return edges
def search(self, graph_id: str, query: str, limit: int = 10, scope: str = "edges") -> Dict[str, Any]:
results = _run_async(
self._client.search(query=query, group_ids=[graph_id], num_results=limit)
max_retries = 3
delay = 2.0
last_exc = None
for attempt in range(max_retries):
try:
results = _run_async(
self._client.search(query=query, group_ids=[graph_id], num_results=limit)
)
edges = [
{
"uuid": getattr(r, "uuid", ""),
"name": getattr(r, "name", ""),
"fact": getattr(r, "fact", ""),
"source_node_uuid": getattr(r, "source_node_uuid", ""),
"target_node_uuid": getattr(r, "target_node_uuid", ""),
}
for r in (results or [])
]
return {"edges": edges, "nodes": []}
except Exception as e:
last_exc = e
logger.debug(
f"Graphiti search attempt {attempt + 1}/{max_retries} failed: "
f"{type(e).__name__}: {e}"
)
if attempt < max_retries - 1:
import time as _time
_time.sleep(delay)
delay *= 2
# Reconnect in case the Neo4j TCP connection dropped
try:
self._client = self._build_client()
except Exception as rebuild_exc:
logger.warning(f"Graphiti client rebuild failed: {rebuild_exc}")
import traceback as _tb
logger.error(
f"Graphiti search failed after {max_retries} attempts: "
f"{type(last_exc).__name__}: {last_exc}\n{_tb.format_exc()}"
)
edges = [
{
"uuid": getattr(r, "uuid", ""),
"name": getattr(r, "name", ""),
"fact": getattr(r, "fact", ""),
"source_node_uuid": getattr(r, "source_node_uuid", ""),
"target_node_uuid": getattr(r, "target_node_uuid", ""),
}
for r in (results or [])
]
return {"edges": edges, "nodes": []}
raise last_exc
def add_text(self, graph_id: str, data: str) -> None:
from graphiti_core.nodes import EpisodeType

View File

@ -11,6 +11,7 @@ Improvements:
import json
import random
import re
import time
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
@ -28,6 +29,19 @@ from .zep_entity_reader import EntityNode, ZepEntityReader
logger = get_logger('mirofish.oasis_profile')
def _normalize_topics(value) -> List[str]:
"""Ensure interested_topics is always List[str], even if the LLM returns a delimited string or a list with a single packed element."""
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
result = []
for item in value:
if isinstance(item, str) and item.strip():
result.extend(part.strip() for part in re.split(r'[,;|\n]+', item) if part.strip())
return result
@dataclass
class OasisAgentProfile:
"""OASIS Agent Profile data structure"""
@ -204,12 +218,13 @@ class OasisProfileGenerator:
default_query=_default_query if _default_query else None
)
# Zep client for enriching context via retrieval
# Graph retrieval client — only initialise Zep when it is the active backend
self.zep_api_key = zep_api_key or Config.ZEP_API_KEY
self.zep_client = None
self.graph_id = graph_id
self._use_graphiti = (Config.GRAPH_BACKEND == "graphiti")
if self.zep_api_key:
if not self._use_graphiti and self.zep_api_key:
try:
self.zep_client = Zep(api_key=self.zep_api_key)
except Exception as e:
@ -274,7 +289,7 @@ class OasisProfileGenerator:
mbti=profile_data.get("mbti"),
country=profile_data.get("country"),
profession=profile_data.get("profession"),
interested_topics=profile_data.get("interested_topics", []),
interested_topics=_normalize_topics(profile_data.get("interested_topics", [])),
source_entity_uuid=entity.uuid,
source_entity_type=entity_type,
)
@ -290,45 +305,84 @@ class OasisProfileGenerator:
return f"{username}_{suffix}"
def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]:
"""Retrieve rich context for an entity via graph hybrid search.
Dispatches to Graphiti (Neo4j) or Zep Cloud depending on the active backend.
"""
Retrieve rich information about an entity using the Zep graph hybrid search.
results = {"facts": [], "node_summaries": [], "context": ""}
Zep has no built-in hybrid search endpoint, so edges and nodes are searched
separately and the results are merged. Parallel requests are used for
efficiency.
Args:
entity: Entity node object
Returns:
Dictionary containing facts, node_summaries, and context
"""
import concurrent.futures
if not self.zep_client:
return {"facts": [], "node_summaries": [], "context": ""}
entity_name = entity.name
results = {
"facts": [],
"node_summaries": [],
"context": ""
}
# graph_id is required for searching
if not self.graph_id:
logger.debug(f"Skipping Zep retrieval: graph_id not set")
logger.debug("Skipping graph retrieval: graph_id not set")
return results
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
def search_edges():
"""Search edges (facts/relationships) - with retry logic"""
max_retries = 3
last_exception = None
delay = 2.0
entity_name = entity.name
if self._use_graphiti:
return self._search_graphiti_for_entity(entity_name, results)
else:
return self._search_zep_cloud_for_entity(entity_name, results)
def _search_graphiti_for_entity(self, entity_name: str, results: Dict[str, Any]) -> Dict[str, Any]:
"""Use the Graphiti backend's search() to retrieve context for an entity."""
import traceback
from ..graph.factory import get_graph_backend
max_retries = 3
delay = 2.0
last_exc = None
for attempt in range(max_retries):
try:
backend = get_graph_backend()
query = t('progress.zepSearchQuery', name=entity_name)
search_result = backend.search(
graph_id=self.graph_id,
query=query,
limit=30,
scope="edges"
)
all_facts = set()
for edge in search_result.get("edges", []):
fact = edge.get("fact", "")
if fact:
all_facts.add(fact)
results["facts"] = list(all_facts)
context_parts = []
if results["facts"]:
context_parts.append("Facts:\n" + "\n".join(f"- {f}" for f in results["facts"][:20]))
results["context"] = "\n\n".join(context_parts)
logger.info(f"Graphiti retrieval complete: {entity_name}, fetched {len(results['facts'])} facts")
return results
except Exception as e:
last_exc = e
if attempt < max_retries - 1:
logger.debug(
f"Graphiti retrieval attempt {attempt + 1} failed ({entity_name}): "
f"{type(e).__name__}: {e} — retrying in {delay}s"
)
time.sleep(delay)
delay *= 2
logger.warning(
f"Graphiti retrieval failed after {max_retries} attempts ({entity_name}): "
f"{type(last_exc).__name__}: {last_exc}\n{traceback.format_exc()}"
)
return results
def _search_zep_cloud_for_entity(self, entity_name: str, results: Dict[str, Any]) -> Dict[str, Any]:
"""Use the Zep Cloud graph.search() to retrieve context for an entity."""
import concurrent.futures
if not self.zep_client:
return results
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
def search_edges():
max_retries = 3
delay = 2.0
for attempt in range(max_retries):
try:
return self.zep_client.graph.search(
@ -339,7 +393,6 @@ class OasisProfileGenerator:
reranker="rrf"
)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.debug(f"Zep edge search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...")
time.sleep(delay)
@ -349,11 +402,8 @@ class OasisProfileGenerator:
return None
def search_nodes():
"""Search nodes (entity summaries) - with retry logic"""
max_retries = 3
last_exception = None
delay = 2.0
for attempt in range(max_retries):
try:
return self.zep_client.graph.search(
@ -364,7 +414,6 @@ class OasisProfileGenerator:
reranker="rrf"
)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.debug(f"Zep node search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...")
time.sleep(delay)
@ -372,18 +421,14 @@ class OasisProfileGenerator:
else:
logger.debug(f"Zep node search failed after {max_retries} attempts: {e}")
return None
try:
# Run edge and node searches in parallel
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
edge_future = executor.submit(search_edges)
node_future = executor.submit(search_nodes)
# Collect results
edge_result = edge_future.result(timeout=30)
node_result = node_future.result(timeout=30)
# Process edge search results
all_facts = set()
if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:
for edge in edge_result.edges:
@ -391,7 +436,6 @@ class OasisProfileGenerator:
all_facts.add(edge.fact)
results["facts"] = list(all_facts)
# Process node search results
all_summaries = set()
if node_result and hasattr(node_result, 'nodes') and node_result.nodes:
for node in node_result.nodes:
@ -401,7 +445,6 @@ class OasisProfileGenerator:
all_summaries.add(f"Related entity: {node.name}")
results["node_summaries"] = list(all_summaries)
# Build comprehensive context
context_parts = []
if results["facts"]:
context_parts.append("Facts:\n" + "\n".join(f"- {f}" for f in results["facts"][:20]))
@ -415,7 +458,7 @@ class OasisProfileGenerator:
logger.warning(f"Zep retrieval timed out ({entity_name})")
except Exception as e:
logger.warning(f"Zep retrieval failed ({entity_name}): {e}")
return results
def _build_entity_context(self, entity: EntityNode) -> str:

View File

@ -0,0 +1,577 @@
# Report PDF/MD Download Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Afegir descàrrega del report generat en format MD i PDF des del frontend, amb un botó desplegable que apareix quan el report s'ha completat.
**Architecture:** S'estén l'endpoint de descàrrega existent `GET /api/report/<id>/download` amb el paràmetre `?format=md|pdf`. El backend converteix `full_report.md` → HTML (via `markdown`) → PDF (via `PyMuPDF` / `fitz.Story`). El frontend afegeix un botó desplegable a `Step4Report.vue` amb opcions MD i PDF que obren una URL de descàrrega directa.
**Tech Stack:** Python `markdown>=3.6` (nova dep), `PyMuPDF>=1.24.0` (ja instal·lat), Vue 3 SPA.
---
## Arxius afectats
| Arxiu | Canvi |
|---|---|
| `backend/pyproject.toml` | +`"markdown>=3.6"` a dependencies |
| `backend/app/api/report.py` | Ampliar `download_report()` amb `?format` + generar PDF |
| `frontend/src/api/report.js` | +`getReportDownloadUrl(reportId, format)` |
| `frontend/src/components/Step4Report.vue` | +botó desplegable MD/PDF quan `isComplete` |
---
## Task 1: Afegir dependència `markdown` al backend
**Files:**
- Modify: `backend/pyproject.toml`
- [ ] **Step 1: Afegir la dependència**
A `backend/pyproject.toml`, afegir `"markdown>=3.6"` a la llista `dependencies`, just després de `"PyMuPDF>=1.24.0"`:
```toml
# 文件处理
"PyMuPDF>=1.24.0",
"markdown>=3.6",
```
- [ ] **Step 2: Instal·lar la dependència**
```bash
cd backend && uv sync
```
Resultat esperat: `markdown` s'instal·la sense errors.
- [ ] **Step 3: Verificar que s'importa correctament**
```bash
cd backend && uv run python -c "import markdown; print(markdown.__version__)"
```
Resultat esperat: imprimeix una versió ≥ 3.6 (p.ex. `3.7`).
- [ ] **Step 4: Commit**
```bash
git add backend/pyproject.toml backend/uv.lock
git commit -m "chore(deps): add markdown>=3.6 for PDF generation"
```
---
## Task 2: Escriure el test de l'endpoint de descàrrega PDF
**Files:**
- Test: `backend/tests/test_report_download.py`
- [ ] **Step 1: Escriure el test**
Crear el fitxer `backend/tests/test_report_download.py`:
```python
"""Tests for report download endpoint (MD and PDF formats)."""
import io
import os
import json
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from app import create_app
@pytest.fixture
def app():
app = create_app({'TESTING': True})
yield app
@pytest.fixture
def client(app):
return app.test_client()
def _make_mock_report(report_id="report_test123", content="# Test Report\n\nHello **world**."):
mock = MagicMock()
mock.report_id = report_id
mock.markdown_content = content
return mock
def _make_md_file(tmp_path, report_id, content):
md_path = os.path.join(tmp_path, f"{report_id}_full_report.md")
with open(md_path, 'w', encoding='utf-8') as f:
f.write(content)
return md_path
class TestDownloadMD:
def test_download_md_format_param(self, client, tmp_path):
"""?format=md returns a .md file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=md')
assert resp.status_code == 200
assert 'attachment' in resp.headers.get('Content-Disposition', '')
assert '.md' in resp.headers.get('Content-Disposition', '')
def test_download_default_is_md(self, client, tmp_path):
"""No format param defaults to md."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download')
assert resp.status_code == 200
assert '.md' in resp.headers.get('Content-Disposition', '')
class TestDownloadPDF:
def test_download_pdf_returns_pdf_bytes(self, client, tmp_path):
"""?format=pdf returns a valid PDF file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=pdf')
assert resp.status_code == 200
assert resp.headers.get('Content-Type', '').startswith('application/pdf')
assert resp.data[:4] == b'%PDF'
def test_download_pdf_report_not_found(self, client):
"""Returns 404 when report does not exist."""
with patch('app.api.report.ReportManager.get_report', return_value=None):
resp = client.get('/api/report/nonexistent/download?format=pdf')
assert resp.status_code == 404
def test_download_pdf_invalid_format(self, client, tmp_path):
"""Returns 400 for unknown format parameter."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=docx')
assert resp.status_code == 400
```
- [ ] **Step 2: Executar els tests per verificar que fallen**
```bash
cd backend && uv run pytest tests/test_report_download.py -v 2>&1 | head -40
```
Resultat esperat: FAILED (la nova funcionalitat PDF encara no existeix).
---
## Task 3: Implementar la generació de PDF al backend
**Files:**
- Modify: `backend/app/api/report.py` (funció `download_report`, línies 398441)
- [ ] **Step 1: Afegir imports al capdamunt de `report.py`**
Localitza el bloc d'imports a `backend/app/api/report.py` (línies 119) i afegeix:
```python
import io
import tempfile
import markdown as md_lib
import fitz # PyMuPDF
```
Si `import io` ja existeix, no el dupliquis. Afegeix els que faltin just després de `import traceback`.
- [ ] **Step 2: Afegir la funció helper `_generate_pdf_bytes`**
Afegeix aquesta funció just **abans** de `@report_bp.route('/<report_id>/download', ...)` (línia 398):
```python
def _generate_pdf_bytes(markdown_content: str) -> bytes:
"""Convert Markdown string to PDF bytes using PyMuPDF (fitz.Story)."""
html_body = md_lib.markdown(
markdown_content,
extensions=['tables', 'fenced_code']
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: sans-serif; font-size: 12pt; line-height: 1.6;
margin: 40px; color: #1a1a1a; }}
h1 {{ font-size: 22pt; border-bottom: 2px solid #333; padding-bottom: 6px; }}
h2 {{ font-size: 16pt; margin-top: 28px; }}
h3 {{ font-size: 13pt; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 4px;
font-size: 10pt; overflow-x: auto; }}
code {{ background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }}
blockquote {{ border-left: 3px solid #aaa; margin-left: 0;
padding-left: 12px; color: #555; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #f0f0f0; }}
</style>
</head>
<body>{html_body}</body>
</html>"""
story = fitz.Story(html)
buf = io.BytesIO()
writer = fitz.DocumentWriter(buf)
mediabox = fitz.paper_rect("a4")
where = mediabox + (36, 36, -36, -36) # margins
more = True
while more:
device = writer.begin_page(mediabox)
more, _ = story.place(where)
story.draw(device)
writer.end_page()
writer.close()
return buf.getvalue()
```
- [ ] **Step 3: Substituir la funció `download_report` completa**
Substitueix tot el contingut de `download_report` (línies 398441) per:
```python
@report_bp.route('/<report_id>/download', methods=['GET'])
def download_report(report_id: str):
"""
Download report in the requested format.
Query params:
format: 'md' (default) | 'pdf'
"""
try:
fmt = request.args.get('format', 'md').lower()
if fmt not in ('md', 'pdf'):
return jsonify({
"success": False,
"error": f"Unsupported format '{fmt}'. Use 'md' or 'pdf'."
}), 400
report = ReportManager.get_report(report_id)
if not report:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
md_path = ReportManager._get_report_markdown_path(report_id)
if os.path.exists(md_path):
with open(md_path, 'r', encoding='utf-8') as f:
markdown_content = f.read()
else:
markdown_content = report.markdown_content
if fmt == 'md':
if os.path.exists(md_path):
return send_file(
md_path,
as_attachment=True,
download_name=f"{report_id}.md"
)
with tempfile.NamedTemporaryFile(mode='w', suffix='.md',
delete=False, encoding='utf-8') as f:
f.write(markdown_content)
temp_path = f.name
return send_file(temp_path, as_attachment=True,
download_name=f"{report_id}.md")
# fmt == 'pdf'
pdf_bytes = _generate_pdf_bytes(markdown_content)
return send_file(
io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True,
download_name=f"{report_id}.pdf"
)
except Exception as e:
logger.error(f"Failed to download report: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
```
- [ ] **Step 4: Executar els tests**
```bash
cd backend && uv run pytest tests/test_report_download.py -v
```
Resultat esperat: tots els tests en PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/app/api/report.py backend/tests/test_report_download.py
git commit -m "feat(report): add PDF download endpoint via PyMuPDF"
```
---
## Task 4: Afegir helper al frontend API
**Files:**
- Modify: `frontend/src/api/report.js`
- [ ] **Step 1: Afegir `getReportDownloadUrl` al final de `report.js`**
Afegeix al final de `frontend/src/api/report.js`:
```javascript
/**
* Build the direct download URL for a report.
* @param {string} reportId
* @param {'md'|'pdf'} format
* @returns {string} URL absoluta per fer servir com a href de descàrrega
*/
export const getReportDownloadUrl = (reportId, format = 'md') => {
const base = import.meta.env.VITE_API_BASE_URL || ''
return `${base}/api/report/${reportId}/download?format=${format}`
}
```
- [ ] **Step 2: Verificar que el frontend compila sense errors**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Resultat esperat: build sense errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/api/report.js
git commit -m "feat(report): add getReportDownloadUrl helper"
```
---
## Task 5: Afegir botó desplegable MD/PDF al component Vue
**Files:**
- Modify: `frontend/src/components/Step4Report.vue`
Els canvis s'estructuren en 3 sub-passos: (a) importar la funció, (b) afegir el template, (c) afegir els estils.
- [ ] **Step 1: Afegir l'import de `getReportDownloadUrl`**
A `Step4Report.vue`, localitza la línia d'imports de `report.js`. Busca quelcom com:
```javascript
import { getReport, ... } from '../api/report'
```
Afegeix `getReportDownloadUrl` a la llista d'imports d'aquell fitxer. Si no hi ha imports de `report.js`, afegeix:
```javascript
import { getReportDownloadUrl } from '../api/report'
```
- [ ] **Step 2: Afegir l'estat reactiu del menú**
Localitza la línia `const isComplete = ref(false)` (línia ~427) i afegeix just a sota:
```javascript
const showDownloadMenu = ref(false)
```
- [ ] **Step 3: Afegir la funció de tancament del menú al clicar fora**
Localitza la funció `goToInteraction` (línia ~410) i afegeix just a sobre:
```javascript
const closeDownloadMenu = () => { showDownloadMenu.value = false }
```
- [ ] **Step 4: Afegir el botó desplegable al template**
Localitza el bloc del botó "next step" al template (línia ~130):
```html
<!-- Next Step Button - 在完成后显示 -->
<button v-if="isComplete" class="next-step-btn" @click="goToInteraction">
```
Afegeix el botó desplegable de descàrrega **just abans** d'aquest bloc:
```html
<!-- Download Button - apareix quan el report és complet -->
<div v-if="isComplete" class="download-wrapper" v-click-outside="closeDownloadMenu">
<button class="download-toggle-btn" @click="showDownloadMenu = !showDownloadMenu">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>{{ $t('step4.downloadReport') }}</span>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="chevron-icon" :class="{ open: showDownloadMenu }">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div v-if="showDownloadMenu" class="download-menu">
<a :href="getReportDownloadUrl(reportId, 'md')" download class="download-option" @click="showDownloadMenu = false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span>Markdown (.md)</span>
</a>
<a :href="getReportDownloadUrl(reportId, 'pdf')" download class="download-option" @click="showDownloadMenu = false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>PDF (.pdf)</span>
</a>
</div>
</div>
```
**Nota:** Vue 3 no té `v-click-outside` built-in. Si el projecte no té aquesta directiva, substitueix `v-click-outside="closeDownloadMenu"` per res i afegeix al `<script setup>` un event listener:
```javascript
// Tanca el menú si es clica fora
import { onMounted, onUnmounted } from 'vue'
const handleClickOutside = (e) => {
if (!e.target.closest('.download-wrapper')) showDownloadMenu.value = false
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
```
- [ ] **Step 5: Afegir les claus i18n**
A `locales/en.json`, dins l'objecte `step4`, afegeix:
```json
"downloadReport": "Download Report"
```
A `locales/zh.json`, dins l'objecte `step4`, afegeix:
```json
"downloadReport": "下载报告"
```
- [ ] **Step 6: Afegir estils CSS**
Al final del bloc `<style>` de `Step4Report.vue` (just abans del tancament `</style>`), afegeix:
```css
/* Download dropdown */
.download-wrapper {
position: relative;
margin: 8px 20px 0 20px;
}
.download-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 11px 20px;
font-size: 13px;
font-weight: 500;
color: #D1D5DB;
background: #111827;
border: 1px solid #374151;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.download-toggle-btn:hover {
background: #1F2937;
color: #F9FAFB;
}
.chevron-icon {
transition: transform 0.2s ease;
margin-left: auto;
}
.chevron-icon.open {
transform: rotate(180deg);
}
.download-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
background: #1F2937;
border: 1px solid #374151;
border-radius: 8px;
overflow: hidden;
z-index: 100;
box-shadow: 0 -4px 12px rgba(0,0,0,0.3);
}
.download-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 13px;
color: #D1D5DB;
text-decoration: none;
transition: background 0.15s ease;
}
.download-option:hover {
background: #374151;
color: #F9FAFB;
}
```
- [ ] **Step 7: Verificar que el frontend compila**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Resultat esperat: build sense errors.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/components/Step4Report.vue locales/en.json locales/zh.json
git commit -m "feat(report): add MD/PDF download dropdown button"
```
---
## Verificació end-to-end
1. Inicia el servidor: `npm run dev` (des de l'arrel del projecte)
2. Genera un report complet en qualsevol projecte existent
3. Quan el report es completa, ha d'aparèixer el botó "Download Report" sobre el botó "Go to Interaction"
4. Fes clic al botó: ha d'aparèixer un menú desplegable amb les opcions "Markdown (.md)" i "PDF (.pdf)"
5. Fes clic a "Markdown (.md)": ha de descarregar-se un fitxer `.md` llegible
6. Fes clic a "PDF (.pdf)": ha de descarregar-se un fitxer `.pdf` que s'obre correctament
7. Tanca el menú clicant fora: ha de tancar-se el desplegable
8. Executa els tests del backend: `cd backend && uv run pytest tests/test_report_download.py -v`

View File

@ -68,3 +68,33 @@ export function getProject(projectId) {
method: 'get'
})
}
/**
* Import a pre-existing ontology JSON (instead of generating one)
* @param {FormData} formData - files, simulation_requirement, ontology (JSON string)
* @returns {Promise}
*/
export function importOntology(formData) {
return requestWithRetry(() =>
service({
url: '/api/graph/ontology/import',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
)
}
/**
* Delete a project
* @param {String} projectId
* @returns {Promise}
*/
export function deleteProject(projectId) {
return service({
url: `/api/graph/project/${projectId}`,
method: 'delete'
})
}

View File

@ -92,9 +92,9 @@
<div v-if="projectData?.ontology?.edge_types" class="tags-container" :class="{ 'dimmed': selectedOntologyItem }">
<span class="tag-label">{{ $t('step1.labelRelationTypes') }}</span>
<div class="tags-list">
<span
v-for="rel in projectData.ontology.edge_types"
:key="rel.name"
<span
v-for="rel in projectData.ontology.edge_types"
:key="rel.name"
class="entity-tag clickable"
@click="selectOntologyItem(rel, 'relation')"
>
@ -102,6 +102,26 @@
</span>
</div>
</div>
<!-- Ontology action buttons (shown when ontology ready but GraphRAG not yet started) -->
<div v-if="ontologyReady && currentPhase <= 0" class="ontology-actions">
<div class="pause-header">
<span class="pause-dot"></span>
<span class="pause-title">{{ $t('step1.pauseTitle') }}</span>
</div>
<p class="pause-desc">{{ $t('step1.pauseDesc') }}</p>
<div class="ontology-btn-row">
<button class="ont-btn download-btn" @click="downloadOntology">
{{ $t('step1.downloadOntology') }}
</button>
<button class="ont-btn delete-btn" @click="confirmDeleteOntology">
× {{ $t('step1.deleteOntology') }}
</button>
</div>
<button class="ont-btn proceed-btn" @click="$emit('proceed-to-graphrag')">
{{ $t('step1.proceedToGraph') }}
</button>
</div>
</div>
</div>
@ -197,6 +217,7 @@ const { t } = useI18n()
const props = defineProps({
currentPhase: { type: Number, default: 0 },
ontologyReady: { type: Boolean, default: false },
projectData: Object,
ontologyProgress: Object,
buildProgress: Object,
@ -204,7 +225,7 @@ const props = defineProps({
systemLogs: { type: Array, default: () => [] }
})
defineEmits(['next-step'])
const emit = defineEmits(['next-step', 'proceed-to-graphrag', 'delete-ontology'])
const selectedOntologyItem = ref(null)
const logContent = ref(null)
@ -249,6 +270,26 @@ const selectOntologyItem = (item, type) => {
selectedOntologyItem.value = { ...item, itemType: type }
}
const downloadOntology = () => {
if (!props.projectData?.ontology) return
const blob = new Blob(
[JSON.stringify(props.projectData.ontology, null, 2)],
{ type: 'application/json' }
)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `ontology_${props.projectData.project_id || 'export'}.json`
a.click()
URL.revokeObjectURL(url)
}
const confirmDeleteOntology = () => {
if (confirm(t('step1.deleteOntologyConfirm'))) {
emit('delete-ontology')
}
}
const graphStats = computed(() => {
const nodes = props.graphData?.node_count || props.graphData?.nodes?.length || 0
const edges = props.graphData?.edge_count || props.graphData?.edges?.length || 0
@ -570,6 +611,87 @@ watch(() => props.systemLogs.length, () => {
color: #BBB;
}
/* Ontology action panel */
.ontology-actions {
margin-top: 16px;
border: 1px solid #E0E0E0;
border-radius: 6px;
padding: 14px;
background: #FAFAFA;
}
.pause-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.pause-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #FF5722;
animation: pulse 1s infinite;
}
.pause-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #333;
font-family: 'JetBrains Mono', monospace;
}
.pause-desc {
font-size: 11px;
color: #666;
line-height: 1.5;
margin-bottom: 12px;
}
.ontology-btn-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.ont-btn {
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
padding: 8px 12px;
font-family: 'JetBrains Mono', monospace;
transition: opacity 0.2s;
}
.ont-btn:hover {
opacity: 0.8;
}
.download-btn {
background: #E8F5E9;
color: #2E7D32;
flex: 1;
}
.delete-btn {
background: #FFEBEE;
color: #C62828;
flex: 1;
}
.proceed-btn {
width: 100%;
background: #000;
color: #FFF;
padding: 12px;
font-size: 12px;
}
/* Step 02 Stats */
.stats-grid {
display: grid;

View File

@ -687,11 +687,49 @@ watch(() => props.systemLogs?.length, () => {
})
})
onMounted(() => {
const doReconnectSimulation = async () => {
addLog(t('log.reconnectingToSim'))
phase.value = 1
emit('update-status', 'processing')
await fetchRunStatus()
await fetchRunStatusDetail()
startStatusPolling()
startDetailPolling()
}
onMounted(async () => {
addLog(t('log.step3Init'))
if (props.simulationId) {
doStartSimulation()
if (!props.simulationId) return
// Check if simulation is already running before starting a new one
try {
const res = await getRunStatus(props.simulationId)
if (res.success && res.data) {
const status = res.data.runner_status
if (status === 'running') {
addLog(t('log.simAlreadyRunning'))
await doReconnectSimulation()
return
}
if (status === 'completed' || status === 'stopped') {
addLog(t('log.simAlreadyCompleted'))
runStatus.value = res.data
phase.value = 2
emit('update-status', 'completed')
await fetchRunStatusDetail()
return
}
if (status === 'failed') {
startError.value = res.data.error || t('common.unknownError')
emit('update-status', 'error')
return
}
}
} catch (err) {
// If status check fails, fall through to start
}
doStartSimulation()
})
onUnmounted(() => {

View File

@ -7,20 +7,26 @@ import { reactive } from 'vue'
const state = reactive({
files: [],
simulationRequirement: '',
isPending: false
isPending: false,
importOntologyMode: false,
ontologyFile: null
})
export function setPendingUpload(files, requirement) {
export function setPendingUpload(files, requirement, importOntologyMode = false, ontologyFile = null) {
state.files = files
state.simulationRequirement = requirement
state.isPending = true
state.importOntologyMode = importOntologyMode
state.ontologyFile = ontologyFile
}
export function getPendingUpload() {
return {
files: state.files,
simulationRequirement: state.simulationRequirement,
isPending: state.isPending
isPending: state.isPending,
importOntologyMode: state.importOntologyMode,
ontologyFile: state.ontologyFile
}
}
@ -28,6 +34,8 @@ export function clearPendingUpload() {
state.files = []
state.simulationRequirement = ''
state.isPending = false
state.importOntologyMode = false
state.ontologyFile = null
}
export default state

View File

@ -133,7 +133,7 @@
<span class="console-meta">{{ $t('home.supportedFormats') }}</span>
</div>
<div
<div
class="upload-zone"
:class="{ 'drag-over': isDragOver, 'has-files': files.length > 0 }"
@dragover.prevent="handleDragOver"
@ -150,13 +150,13 @@
style="display: none"
:disabled="loading"
/>
<div v-if="files.length === 0" class="upload-placeholder">
<div class="upload-icon"></div>
<div class="upload-title">{{ $t('home.dragToUpload') }}</div>
<div class="upload-hint">{{ $t('home.orBrowse') }}</div>
</div>
<div v-else class="file-list">
<div v-for="(file, index) in files" :key="index" class="file-item">
<span class="file-icon">📄</span>
@ -165,6 +165,34 @@
</div>
</div>
</div>
<!-- Import ontology toggle -->
<div class="import-toggle-row">
<label class="import-toggle-label">
<input
type="checkbox"
v-model="importOntologyMode"
:disabled="loading"
class="import-checkbox"
/>
<span class="import-toggle-text">{{ $t('home.importOntology') }}</span>
</label>
</div>
<!-- Ontology JSON file selector (visible when importOntologyMode) -->
<div v-if="importOntologyMode" class="ontology-file-row" @click="triggerOntologyInput">
<input
ref="ontologyFileInput"
type="file"
accept=".json"
@change="handleOntologyFileSelect"
style="display: none"
:disabled="loading"
/>
<span class="ontology-file-icon">{ }</span>
<span class="ontology-file-name">{{ ontologyFile ? ontologyFile.name : $t('home.importOntologyHint') }}</span>
<span v-if="ontologyFile" class="remove-btn" @click.stop="ontologyFile = null">×</span>
</div>
</div>
<!-- 分割线 -->
@ -234,10 +262,17 @@ const isDragOver = ref(false)
//
const fileInput = ref(null)
const ontologyFileInput = ref(null)
// Import ontology mode
const importOntologyMode = ref(false)
const ontologyFile = ref(null)
// :
const canSubmit = computed(() => {
return formData.value.simulationRequirement.trim() !== '' && files.value.length > 0
if (!formData.value.simulationRequirement.trim() || files.value.length === 0) return false
if (importOntologyMode.value && !ontologyFile.value) return false
return true
})
//
@ -286,6 +321,21 @@ const removeFile = (index) => {
files.value.splice(index, 1)
}
// Ontology file input
const triggerOntologyInput = () => {
if (!loading.value) {
ontologyFileInput.value?.click()
}
}
const handleOntologyFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
ontologyFile.value = file
}
event.target.value = ''
}
//
const scrollToBottom = () => {
window.scrollTo({
@ -300,9 +350,13 @@ const startSimulation = () => {
//
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
setPendingUpload(files.value, formData.value.simulationRequirement)
// Process使
setPendingUpload(
files.value,
formData.value.simulationRequirement,
importOntologyMode.value,
ontologyFile.value
)
router.push({
name: 'Process',
params: { projectId: 'new' }
@ -773,6 +827,64 @@ const startSimulation = () => {
color: #999;
}
.import-toggle-row {
display: flex;
align-items: center;
padding: 10px 0 4px;
}
.import-toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.75rem;
color: #666;
user-select: none;
}
.import-checkbox {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--orange);
}
.import-toggle-text {
font-weight: 600;
letter-spacing: 0.5px;
}
.ontology-file-row {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed #CCC;
padding: 10px 14px;
cursor: pointer;
background: #FAFAFA;
font-family: var(--font-mono);
font-size: 0.8rem;
color: #666;
transition: all 0.2s;
margin-top: 6px;
}
.ontology-file-row:hover {
border-color: var(--orange);
color: #333;
}
.ontology-file-icon {
color: var(--orange);
font-weight: 700;
}
.ontology-file-name {
flex: 1;
}
.console-divider {
display: flex;
align-items: center;

View File

@ -51,15 +51,18 @@
<!-- Right Panel: Step Components -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<!-- Step 1: 图谱构建 -->
<Step1GraphBuild
<Step1GraphBuild
v-if="currentStep === 1"
:currentPhase="currentPhase"
:ontologyReady="ontologyReady"
:projectData="projectData"
:ontologyProgress="ontologyProgress"
:buildProgress="buildProgress"
:graphData="graphData"
:systemLogs="systemLogs"
@next-step="handleNextStep"
@proceed-to-graphrag="handleProceedToGraphRAG"
@delete-ontology="handleDeleteOntology"
/>
<!-- Step 2: 环境搭建 -->
<Step2EnvSetup
@ -83,7 +86,7 @@ import { useI18n } from 'vue-i18n'
import GraphPanel from '../components/GraphPanel.vue'
import Step1GraphBuild from '../components/Step1GraphBuild.vue'
import Step2EnvSetup from '../components/Step2EnvSetup.vue'
import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'
import { generateOntology, importOntology, getProject, buildGraph, getTaskStatus, getGraphData, deleteProject } from '../api/graph'
import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
@ -106,6 +109,7 @@ const error = ref('')
const projectData = ref(null)
const graphData = ref(null)
const currentPhase = ref(-1) // -1: Upload, 0: Ontology, 1: Build, 2: Complete
const ontologyReady = ref(false) // true = ontology done but GraphRAG not yet started
const ontologyProgress = ref(null)
const buildProgress = ref(null)
const systemLogs = ref([])
@ -198,30 +202,40 @@ const handleNewProject = async () => {
addLog('Error: No pending files found for new project.')
return
}
try {
loading.value = true
currentPhase.value = 0
ontologyProgress.value = { message: t('step1.analyzingDocs') }
addLog('Starting ontology generation: Uploading files...')
const formData = new FormData()
pending.files.forEach(f => formData.append('files', f))
formData.append('simulation_requirement', pending.simulationRequirement)
const res = await generateOntology(formData)
let res
if (pending.importOntologyMode && pending.ontologyFile) {
addLog('Importing ontology from JSON file...')
const ontologyText = await pending.ontologyFile.text()
formData.append('ontology', ontologyText)
res = await importOntology(formData)
} else {
addLog('Starting ontology generation: Uploading files...')
res = await generateOntology(formData)
}
if (res.success) {
clearPendingUpload()
currentProjectId.value = res.data.project_id
projectData.value = res.data
router.replace({ name: 'Process', params: { projectId: res.data.project_id } })
ontologyProgress.value = null
addLog(`Ontology generated successfully for project ${res.data.project_id}`)
await startBuildGraph()
addLog(`Ontology ready for project ${res.data.project_id}. Waiting for confirmation to build GraphRAG.`)
ontologyReady.value = true
// Do NOT auto-start build user must click "Proceed to GraphRAG"
} else {
error.value = res.error || 'Ontology generation failed'
addLog(`Error generating ontology: ${error.value}`)
error.value = res.error || 'Ontology step failed'
addLog(`Error: ${error.value}`)
}
} catch (err) {
error.value = err.message
@ -399,6 +413,24 @@ const stopGraphPolling = () => {
}
}
const handleProceedToGraphRAG = async () => {
ontologyReady.value = false
addLog('User confirmed: starting GraphRAG build...')
await startBuildGraph()
}
const handleDeleteOntology = async () => {
if (!currentProjectId.value || currentProjectId.value === 'new') return
addLog(`Deleting project ${currentProjectId.value}...`)
try {
await deleteProject(currentProjectId.value)
addLog('Project deleted. Returning to home.')
router.push('/')
} catch (err) {
addLog(`Error deleting project: ${err.message}`)
}
}
onMounted(() => {
initProject()
})

View File

@ -69,7 +69,9 @@
"promptPlaceholder": "// Descriu el teu requisit de simulació o predicció en llenguatge natural",
"engineBadge": "Motor: MiroFish-V1.0",
"startEngine": "Inicia el motor",
"initializing": "Inicialitzant..."
"initializing": "Inicialitzant...",
"importOntology": "Importa ontologia",
"importOntologyHint": "Selecciona el fitxer JSON d'ontologia"
},
"main": {
"layoutGraph": "Graf",
@ -114,7 +116,13 @@
"labelRelation": "RELACIÓ",
"labelAttributes": "ATRIBUTS",
"labelExamples": "EXEMPLES",
"labelConnections": "CONNEXIONS"
"labelConnections": "CONNEXIONS",
"pauseTitle": "Ontologia llesta",
"pauseDesc": "Revisa l'ontologia generada abans de construir el graf de coneixement.",
"downloadOntology": "Descarregar ontologia JSON",
"deleteOntology": "Esborrar ontologia",
"proceedToGraph": "Continuar a GraphRAG →",
"deleteOntologyConfirm": "Esborrar aquesta ontologia i tornar a l'inici?"
},
"step2": {
"simInstanceInit": "Inicialització de la instància de simulació",
@ -352,6 +360,9 @@
"graphBuilding": "Construcció del graf en curs. No reenvïis. Per forçar la reconstrucció, afegeix force: true.",
"textNotFound": "No s'ha trobat el contingut de text extret",
"ontologyNotFound": "No s'ha trobat la definició de l'ontologia",
"requireOntologyJson": "Cal proporcionar el JSON d'ontologia (camp ontology)",
"invalidOntologyJson": "Format JSON d'ontologia no vàlid",
"invalidOntologyStructure": "L'ontologia ha de tenir arrays entity_types i edge_types",
"graphBuildStarted": "Tasca de construcció del graf iniciada. Consulta el progrés a /task/{taskId}.",
"graphBuildComplete": "Construcció del graf completada",
"buildFailed": "Error en la construcció: {error}",
@ -543,6 +554,9 @@
"loadConfigFailed": "Error en carregar la configuració: {error}",
"step2Init": "Configuració de l'entorn del pas 2 inicialitzada",
"step3Init": "Execució de la simulació del pas 3 inicialitzada",
"reconnectingToSim": "Reconnectant a la simulació en curs...",
"simAlreadyRunning": "La simulació ja s'està executant, reconnectant...",
"simAlreadyCompleted": "La simulació ja ha finalitzat",
"startingDualSim": "Iniciant la simulació paral·lela en dues plataformes...",
"setMaxRounds": "Nombre màxim de rondes de simulació establert a: {rounds}",
"graphMemoryUpdateEnabled": "Actualització dinàmica de la memòria del graf activada",

View File

@ -69,7 +69,9 @@
"promptPlaceholder": "// Describe your simulation or prediction requirement in natural language",
"engineBadge": "Engine: MiroFish-V1.0",
"startEngine": "Start Engine",
"initializing": "Initializing..."
"initializing": "Initializing...",
"importOntology": "Import Ontology",
"importOntologyHint": "Select ontology JSON file"
},
"main": {
"layoutGraph": "Graph",
@ -114,7 +116,13 @@
"labelRelation": "RELATION",
"labelAttributes": "ATTRIBUTES",
"labelExamples": "EXAMPLES",
"labelConnections": "CONNECTIONS"
"labelConnections": "CONNECTIONS",
"pauseTitle": "Ontology Ready",
"pauseDesc": "Review the generated ontology before building the knowledge graph.",
"downloadOntology": "Download Ontology JSON",
"deleteOntology": "Delete Ontology",
"proceedToGraph": "Proceed to GraphRAG →",
"deleteOntologyConfirm": "Delete this ontology and return to home?"
},
"step2": {
"simInstanceInit": "Simulation Instance Initialization",
@ -353,6 +361,9 @@
"graphBuilding": "Graph build in progress. Do not resubmit. To force rebuild, add force: true.",
"textNotFound": "Extracted text content not found",
"ontologyNotFound": "Ontology definition not found",
"requireOntologyJson": "Please provide the ontology JSON (ontology field)",
"invalidOntologyJson": "Invalid ontology JSON format",
"invalidOntologyStructure": "Ontology must have entity_types and edge_types arrays",
"graphBuildStarted": "Graph build task started. Query progress via /task/{taskId}.",
"graphBuildComplete": "Graph build complete",
"buildFailed": "Build failed: {error}",
@ -544,6 +555,9 @@
"loadConfigFailed": "Failed to load config: {error}",
"step2Init": "Step 2 environment setup initialized",
"step3Init": "Step 3 simulation run initialized",
"reconnectingToSim": "Reconnecting to running simulation...",
"simAlreadyRunning": "Simulation already running, reconnecting...",
"simAlreadyCompleted": "Simulation already completed",
"startingDualSim": "Starting dual-platform parallel simulation...",
"setMaxRounds": "Max simulation rounds set to: {rounds}",
"graphMemoryUpdateEnabled": "Dynamic graph memory update enabled",

View File

@ -69,7 +69,9 @@
"promptPlaceholder": "// Describe tu requisito de simulación o predicción en lenguaje natural",
"engineBadge": "Motor: MiroFish-V1.0",
"startEngine": "Iniciar motor",
"initializing": "Inicializando..."
"initializing": "Inicializando...",
"importOntology": "Importar ontología",
"importOntologyHint": "Seleccionar archivo JSON de ontología"
},
"main": {
"layoutGraph": "Grafo",
@ -114,7 +116,13 @@
"labelRelation": "RELACIÓN",
"labelAttributes": "ATRIBUTOS",
"labelExamples": "EJEMPLOS",
"labelConnections": "CONEXIONES"
"labelConnections": "CONEXIONES",
"pauseTitle": "Ontología lista",
"pauseDesc": "Revisa la ontología generada antes de construir el grafo de conocimiento.",
"downloadOntology": "Descargar ontología JSON",
"deleteOntology": "Eliminar ontología",
"proceedToGraph": "Continuar a GraphRAG →",
"deleteOntologyConfirm": "¿Eliminar esta ontología y volver al inicio?"
},
"step2": {
"simInstanceInit": "Inicialización de instancia de simulación",
@ -352,6 +360,9 @@
"graphBuilding": "Construcción del grafo en progreso. No reenvíes. Para forzar reconstrucción, agregá force: true.",
"textNotFound": "Contenido de texto extraído no encontrado",
"ontologyNotFound": "Definición de ontología no encontrada",
"requireOntologyJson": "Por favor proporciona el JSON de ontología (campo ontology)",
"invalidOntologyJson": "Formato JSON de ontología no válido",
"invalidOntologyStructure": "La ontología debe tener arrays entity_types y edge_types",
"graphBuildStarted": "Tarea de construcción del grafo iniciada. Consultá progreso vía /task/{taskId}.",
"graphBuildComplete": "Construcción del grafo completa",
"buildFailed": "Construcción falló: {error}",
@ -543,6 +554,9 @@
"loadConfigFailed": "Error al cargar configuración: {error}",
"step2Init": "Paso 2: configuración del entorno inicializada",
"step3Init": "Paso 3: ejecución de simulación inicializada",
"reconnectingToSim": "Reconectando a la simulación en curso...",
"simAlreadyRunning": "Simulación ya en curso, reconectando...",
"simAlreadyCompleted": "Simulación ya completada",
"startingDualSim": "Iniciando simulación paralela de doble plataforma...",
"setMaxRounds": "Rondas máximas de simulación configuradas en: {rounds}",
"graphMemoryUpdateEnabled": "Actualización dinámica de memoria del grafo habilitada",

View File

@ -69,7 +69,9 @@
"promptPlaceholder": "// 用自然语言输入模拟或预测需求(例.武大若发布撤销肖某处分的公告,会引发什么舆情走向)",
"engineBadge": "引擎: MiroFish-V1.0",
"startEngine": "启动引擎",
"initializing": "初始化中..."
"initializing": "初始化中...",
"importOntology": "导入本体",
"importOntologyHint": "选择本体JSON文件"
},
"main": {
"layoutGraph": "图谱",
@ -114,7 +116,13 @@
"labelRelation": "关系",
"labelAttributes": "属性",
"labelExamples": "示例",
"labelConnections": "连接"
"labelConnections": "连接",
"pauseTitle": "本体已就绪",
"pauseDesc": "在构建知识图谱前,请检查生成的本体。",
"downloadOntology": "下载本体JSON",
"deleteOntology": "删除本体",
"proceedToGraph": "继续构建GraphRAG →",
"deleteOntologyConfirm": "删除此本体并返回首页?"
},
"step2": {
"simInstanceInit": "模拟实例初始化",
@ -353,6 +361,9 @@
"graphBuilding": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true",
"textNotFound": "未找到提取的文本内容",
"ontologyNotFound": "未找到本体定义",
"requireOntologyJson": "请提供本体JSONontology字段",
"invalidOntologyJson": "本体JSON格式无效",
"invalidOntologyStructure": "本体必须包含entity_types和edge_types数组",
"graphBuildStarted": "图谱构建任务已启动,请通过 /task/{taskId} 查询进度",
"graphBuildComplete": "图谱构建完成",
"buildFailed": "构建失败: {error}",
@ -544,6 +555,9 @@
"loadConfigFailed": "加载配置失败: {error}",
"step2Init": "Step2 环境搭建初始化",
"step3Init": "Step3 模拟运行初始化",
"reconnectingToSim": "正在重新连接到运行中的模拟...",
"simAlreadyRunning": "模拟已在运行,正在重新连接...",
"simAlreadyCompleted": "模拟已完成",
"startingDualSim": "正在启动双平台并行模拟...",
"setMaxRounds": "设置最大模拟轮数: {rounds}",
"graphMemoryUpdateEnabled": "已开启动态图谱更新模式",