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:
parent
f854a0de12
commit
d09ee23cd3
|
|
@ -63,3 +63,7 @@ data/
|
||||||
|
|
||||||
# Configuració Azure amb secrets (no comitejar mai)
|
# Configuració Azure amb secrets (no comitejar mai)
|
||||||
azure/config.sh
|
azure/config.sh
|
||||||
|
|
||||||
|
# Fitxers locals de treball (no per al repo)
|
||||||
|
exports.txt
|
||||||
|
docs/2026-04-26-enterprise-roadmap.md
|
||||||
|
|
|
||||||
|
|
@ -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à).
|
||||||
|
|
@ -4,6 +4,7 @@ Uses project context mechanism with server-side persistent state
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
import threading
|
import threading
|
||||||
from flask import request, jsonify
|
from flask import request, jsonify
|
||||||
|
|
@ -255,6 +256,126 @@ def generate_ontology():
|
||||||
}), 500
|
}), 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 ==============
|
# ============== Endpoint 2: Build graph ==============
|
||||||
|
|
||||||
@graph_bp.route('/build', methods=['POST'])
|
@graph_bp.route('/build', methods=['POST'])
|
||||||
|
|
|
||||||
|
|
@ -1397,7 +1397,7 @@ def generate_profiles():
|
||||||
"error": t('api.noMatchingEntities')
|
"error": t('api.noMatchingEntities')
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
generator = OasisProfileGenerator()
|
generator = OasisProfileGenerator(graph_id=graph_id)
|
||||||
profiles = generator.generate_profiles_from_entities(
|
profiles = generator.generate_profiles_from_entities(
|
||||||
entities=filtered.entities,
|
entities=filtered.entities,
|
||||||
use_llm=use_llm
|
use_llm=use_llm
|
||||||
|
|
|
||||||
|
|
@ -196,22 +196,51 @@ class GraphitiBackend(GraphBackend):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _patch_extract_entity_attributes() -> None:
|
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
|
1. _extract_entity_attributes: some LLMs wrap attribute values in nested
|
||||||
of plain strings. Neo4j rejects these with TypeError. We intercept the raw
|
dicts ({"value": "CTTI"}). Neo4j rejects these — flatten them.
|
||||||
llm_response dict before it is stored in node.attributes and flatten it.
|
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
|
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):
|
async def _patched_attrs(llm_client, node, episode, previous_episodes, entity_type):
|
||||||
result = await original(llm_client, node, episode, previous_episodes, entity_type)
|
result = await original_attrs(llm_client, node, episode, previous_episodes, entity_type)
|
||||||
# result is a dict — flatten any dict-valued attributes
|
|
||||||
return _flatten_attributes(result) if result else result
|
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:
|
def create_graph(self, graph_id: str, name: str, description: str = "") -> None:
|
||||||
logger.info(f"Graphiti graph namespace ready: {graph_id}")
|
logger.info(f"Graphiti graph namespace ready: {graph_id}")
|
||||||
|
|
@ -428,6 +457,11 @@ class GraphitiBackend(GraphBackend):
|
||||||
return edges
|
return edges
|
||||||
|
|
||||||
def search(self, graph_id: str, query: str, limit: int = 10, scope: str = "edges") -> Dict[str, Any]:
|
def search(self, graph_id: str, query: str, limit: int = 10, scope: str = "edges") -> Dict[str, Any]:
|
||||||
|
max_retries = 3
|
||||||
|
delay = 2.0
|
||||||
|
last_exc = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
results = _run_async(
|
results = _run_async(
|
||||||
self._client.search(query=query, group_ids=[graph_id], num_results=limit)
|
self._client.search(query=query, group_ids=[graph_id], num_results=limit)
|
||||||
)
|
)
|
||||||
|
|
@ -442,6 +476,27 @@ class GraphitiBackend(GraphBackend):
|
||||||
for r in (results or [])
|
for r in (results or [])
|
||||||
]
|
]
|
||||||
return {"edges": edges, "nodes": []}
|
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()}"
|
||||||
|
)
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
def add_text(self, graph_id: str, data: str) -> None:
|
def add_text(self, graph_id: str, data: str) -> None:
|
||||||
from graphiti_core.nodes import EpisodeType
|
from graphiti_core.nodes import EpisodeType
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Improvements:
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
@ -28,6 +29,19 @@ from .zep_entity_reader import EntityNode, ZepEntityReader
|
||||||
logger = get_logger('mirofish.oasis_profile')
|
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
|
@dataclass
|
||||||
class OasisAgentProfile:
|
class OasisAgentProfile:
|
||||||
"""OASIS Agent Profile data structure"""
|
"""OASIS Agent Profile data structure"""
|
||||||
|
|
@ -204,12 +218,13 @@ class OasisProfileGenerator:
|
||||||
default_query=_default_query if _default_query else None
|
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_api_key = zep_api_key or Config.ZEP_API_KEY
|
||||||
self.zep_client = None
|
self.zep_client = None
|
||||||
self.graph_id = graph_id
|
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:
|
try:
|
||||||
self.zep_client = Zep(api_key=self.zep_api_key)
|
self.zep_client = Zep(api_key=self.zep_api_key)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -274,7 +289,7 @@ class OasisProfileGenerator:
|
||||||
mbti=profile_data.get("mbti"),
|
mbti=profile_data.get("mbti"),
|
||||||
country=profile_data.get("country"),
|
country=profile_data.get("country"),
|
||||||
profession=profile_data.get("profession"),
|
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_uuid=entity.uuid,
|
||||||
source_entity_type=entity_type,
|
source_entity_type=entity_type,
|
||||||
)
|
)
|
||||||
|
|
@ -290,45 +305,84 @@ class OasisProfileGenerator:
|
||||||
return f"{username}_{suffix}"
|
return f"{username}_{suffix}"
|
||||||
|
|
||||||
def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]:
|
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
|
if not self.graph_id:
|
||||||
separately and the results are merged. Parallel requests are used for
|
logger.debug("Skipping graph retrieval: graph_id not set")
|
||||||
efficiency.
|
return results
|
||||||
|
|
||||||
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
|
entity_name = entity.name
|
||||||
|
|
||||||
results = {
|
if self._use_graphiti:
|
||||||
"facts": [],
|
return self._search_graphiti_for_entity(entity_name, results)
|
||||||
"node_summaries": [],
|
else:
|
||||||
"context": ""
|
return self._search_zep_cloud_for_entity(entity_name, results)
|
||||||
}
|
|
||||||
|
|
||||||
# graph_id is required for searching
|
def _search_graphiti_for_entity(self, entity_name: str, results: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if not self.graph_id:
|
"""Use the Graphiti backend's search() to retrieve context for an entity."""
|
||||||
logger.debug(f"Skipping Zep retrieval: graph_id not set")
|
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
|
return results
|
||||||
|
|
||||||
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
|
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
|
||||||
|
|
||||||
def search_edges():
|
def search_edges():
|
||||||
"""Search edges (facts/relationships) - with retry logic"""
|
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
last_exception = None
|
|
||||||
delay = 2.0
|
delay = 2.0
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
return self.zep_client.graph.search(
|
return self.zep_client.graph.search(
|
||||||
|
|
@ -339,7 +393,6 @@ class OasisProfileGenerator:
|
||||||
reranker="rrf"
|
reranker="rrf"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exception = e
|
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
logger.debug(f"Zep edge search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...")
|
logger.debug(f"Zep edge search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...")
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
@ -349,11 +402,8 @@ class OasisProfileGenerator:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def search_nodes():
|
def search_nodes():
|
||||||
"""Search nodes (entity summaries) - with retry logic"""
|
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
last_exception = None
|
|
||||||
delay = 2.0
|
delay = 2.0
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
return self.zep_client.graph.search(
|
return self.zep_client.graph.search(
|
||||||
|
|
@ -364,7 +414,6 @@ class OasisProfileGenerator:
|
||||||
reranker="rrf"
|
reranker="rrf"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exception = e
|
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
logger.debug(f"Zep node search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...")
|
logger.debug(f"Zep node search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...")
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
@ -374,16 +423,12 @@ class OasisProfileGenerator:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run edge and node searches in parallel
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
edge_future = executor.submit(search_edges)
|
edge_future = executor.submit(search_edges)
|
||||||
node_future = executor.submit(search_nodes)
|
node_future = executor.submit(search_nodes)
|
||||||
|
|
||||||
# Collect results
|
|
||||||
edge_result = edge_future.result(timeout=30)
|
edge_result = edge_future.result(timeout=30)
|
||||||
node_result = node_future.result(timeout=30)
|
node_result = node_future.result(timeout=30)
|
||||||
|
|
||||||
# Process edge search results
|
|
||||||
all_facts = set()
|
all_facts = set()
|
||||||
if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:
|
if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:
|
||||||
for edge in edge_result.edges:
|
for edge in edge_result.edges:
|
||||||
|
|
@ -391,7 +436,6 @@ class OasisProfileGenerator:
|
||||||
all_facts.add(edge.fact)
|
all_facts.add(edge.fact)
|
||||||
results["facts"] = list(all_facts)
|
results["facts"] = list(all_facts)
|
||||||
|
|
||||||
# Process node search results
|
|
||||||
all_summaries = set()
|
all_summaries = set()
|
||||||
if node_result and hasattr(node_result, 'nodes') and node_result.nodes:
|
if node_result and hasattr(node_result, 'nodes') and node_result.nodes:
|
||||||
for node in node_result.nodes:
|
for node in node_result.nodes:
|
||||||
|
|
@ -401,7 +445,6 @@ class OasisProfileGenerator:
|
||||||
all_summaries.add(f"Related entity: {node.name}")
|
all_summaries.add(f"Related entity: {node.name}")
|
||||||
results["node_summaries"] = list(all_summaries)
|
results["node_summaries"] = list(all_summaries)
|
||||||
|
|
||||||
# Build comprehensive context
|
|
||||||
context_parts = []
|
context_parts = []
|
||||||
if results["facts"]:
|
if results["facts"]:
|
||||||
context_parts.append("Facts:\n" + "\n".join(f"- {f}" for f in results["facts"][:20]))
|
context_parts.append("Facts:\n" + "\n".join(f"- {f}" for f in results["facts"][:20]))
|
||||||
|
|
|
||||||
|
|
@ -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 398–441)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Afegir imports al capdamunt de `report.py`**
|
||||||
|
|
||||||
|
Localitza el bloc d'imports a `backend/app/api/report.py` (línies 1–19) 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 398–441) 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`
|
||||||
|
|
@ -68,3 +68,33 @@ export function getProject(projectId) {
|
||||||
method: 'get'
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,26 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -197,6 +217,7 @@ const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentPhase: { type: Number, default: 0 },
|
currentPhase: { type: Number, default: 0 },
|
||||||
|
ontologyReady: { type: Boolean, default: false },
|
||||||
projectData: Object,
|
projectData: Object,
|
||||||
ontologyProgress: Object,
|
ontologyProgress: Object,
|
||||||
buildProgress: Object,
|
buildProgress: Object,
|
||||||
|
|
@ -204,7 +225,7 @@ const props = defineProps({
|
||||||
systemLogs: { type: Array, default: () => [] }
|
systemLogs: { type: Array, default: () => [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['next-step'])
|
const emit = defineEmits(['next-step', 'proceed-to-graphrag', 'delete-ontology'])
|
||||||
|
|
||||||
const selectedOntologyItem = ref(null)
|
const selectedOntologyItem = ref(null)
|
||||||
const logContent = ref(null)
|
const logContent = ref(null)
|
||||||
|
|
@ -249,6 +270,26 @@ const selectOntologyItem = (item, type) => {
|
||||||
selectedOntologyItem.value = { ...item, itemType: 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 graphStats = computed(() => {
|
||||||
const nodes = props.graphData?.node_count || props.graphData?.nodes?.length || 0
|
const nodes = props.graphData?.node_count || props.graphData?.nodes?.length || 0
|
||||||
const edges = props.graphData?.edge_count || props.graphData?.edges?.length || 0
|
const edges = props.graphData?.edge_count || props.graphData?.edges?.length || 0
|
||||||
|
|
@ -570,6 +611,87 @@ watch(() => props.systemLogs.length, () => {
|
||||||
color: #BBB;
|
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 */
|
/* Step 02 Stats */
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
addLog(t('log.step3Init'))
|
||||||
if (props.simulationId) {
|
if (!props.simulationId) return
|
||||||
doStartSimulation()
|
|
||||||
|
// 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(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,26 @@ import { reactive } from 'vue'
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
files: [],
|
files: [],
|
||||||
simulationRequirement: '',
|
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.files = files
|
||||||
state.simulationRequirement = requirement
|
state.simulationRequirement = requirement
|
||||||
state.isPending = true
|
state.isPending = true
|
||||||
|
state.importOntologyMode = importOntologyMode
|
||||||
|
state.ontologyFile = ontologyFile
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPendingUpload() {
|
export function getPendingUpload() {
|
||||||
return {
|
return {
|
||||||
files: state.files,
|
files: state.files,
|
||||||
simulationRequirement: state.simulationRequirement,
|
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.files = []
|
||||||
state.simulationRequirement = ''
|
state.simulationRequirement = ''
|
||||||
state.isPending = false
|
state.isPending = false
|
||||||
|
state.importOntologyMode = false
|
||||||
|
state.ontologyFile = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default state
|
export default state
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,34 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 分割线 -->
|
<!-- 分割线 -->
|
||||||
|
|
@ -234,10 +262,17 @@ const isDragOver = ref(false)
|
||||||
|
|
||||||
// 文件输入引用
|
// 文件输入引用
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
const ontologyFileInput = ref(null)
|
||||||
|
|
||||||
|
// Import ontology mode
|
||||||
|
const importOntologyMode = ref(false)
|
||||||
|
const ontologyFile = ref(null)
|
||||||
|
|
||||||
// 计算属性:是否可以提交
|
// 计算属性:是否可以提交
|
||||||
const canSubmit = computed(() => {
|
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)
|
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 = () => {
|
const scrollToBottom = () => {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
|
|
@ -300,9 +350,13 @@ const startSimulation = () => {
|
||||||
|
|
||||||
// 存储待上传的数据
|
// 存储待上传的数据
|
||||||
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
|
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
|
||||||
setPendingUpload(files.value, formData.value.simulationRequirement)
|
setPendingUpload(
|
||||||
|
files.value,
|
||||||
|
formData.value.simulationRequirement,
|
||||||
|
importOntologyMode.value,
|
||||||
|
ontologyFile.value
|
||||||
|
)
|
||||||
|
|
||||||
// 立即跳转到Process页面(使用特殊标识表示新建项目)
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Process',
|
name: 'Process',
|
||||||
params: { projectId: 'new' }
|
params: { projectId: 'new' }
|
||||||
|
|
@ -773,6 +827,64 @@ const startSimulation = () => {
|
||||||
color: #999;
|
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 {
|
.console-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,15 @@
|
||||||
<Step1GraphBuild
|
<Step1GraphBuild
|
||||||
v-if="currentStep === 1"
|
v-if="currentStep === 1"
|
||||||
:currentPhase="currentPhase"
|
:currentPhase="currentPhase"
|
||||||
|
:ontologyReady="ontologyReady"
|
||||||
:projectData="projectData"
|
:projectData="projectData"
|
||||||
:ontologyProgress="ontologyProgress"
|
:ontologyProgress="ontologyProgress"
|
||||||
:buildProgress="buildProgress"
|
:buildProgress="buildProgress"
|
||||||
:graphData="graphData"
|
:graphData="graphData"
|
||||||
:systemLogs="systemLogs"
|
:systemLogs="systemLogs"
|
||||||
@next-step="handleNextStep"
|
@next-step="handleNextStep"
|
||||||
|
@proceed-to-graphrag="handleProceedToGraphRAG"
|
||||||
|
@delete-ontology="handleDeleteOntology"
|
||||||
/>
|
/>
|
||||||
<!-- Step 2: 环境搭建 -->
|
<!-- Step 2: 环境搭建 -->
|
||||||
<Step2EnvSetup
|
<Step2EnvSetup
|
||||||
|
|
@ -83,7 +86,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import GraphPanel from '../components/GraphPanel.vue'
|
import GraphPanel from '../components/GraphPanel.vue'
|
||||||
import Step1GraphBuild from '../components/Step1GraphBuild.vue'
|
import Step1GraphBuild from '../components/Step1GraphBuild.vue'
|
||||||
import Step2EnvSetup from '../components/Step2EnvSetup.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 { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||||
|
|
||||||
|
|
@ -106,6 +109,7 @@ const error = ref('')
|
||||||
const projectData = ref(null)
|
const projectData = ref(null)
|
||||||
const graphData = ref(null)
|
const graphData = ref(null)
|
||||||
const currentPhase = ref(-1) // -1: Upload, 0: Ontology, 1: Build, 2: Complete
|
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 ontologyProgress = ref(null)
|
||||||
const buildProgress = ref(null)
|
const buildProgress = ref(null)
|
||||||
const systemLogs = ref([])
|
const systemLogs = ref([])
|
||||||
|
|
@ -203,13 +207,22 @@ const handleNewProject = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
currentPhase.value = 0
|
currentPhase.value = 0
|
||||||
ontologyProgress.value = { message: t('step1.analyzingDocs') }
|
ontologyProgress.value = { message: t('step1.analyzingDocs') }
|
||||||
addLog('Starting ontology generation: Uploading files...')
|
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
pending.files.forEach(f => formData.append('files', f))
|
pending.files.forEach(f => formData.append('files', f))
|
||||||
formData.append('simulation_requirement', pending.simulationRequirement)
|
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) {
|
if (res.success) {
|
||||||
clearPendingUpload()
|
clearPendingUpload()
|
||||||
currentProjectId.value = res.data.project_id
|
currentProjectId.value = res.data.project_id
|
||||||
|
|
@ -217,11 +230,12 @@ const handleNewProject = async () => {
|
||||||
|
|
||||||
router.replace({ name: 'Process', params: { projectId: res.data.project_id } })
|
router.replace({ name: 'Process', params: { projectId: res.data.project_id } })
|
||||||
ontologyProgress.value = null
|
ontologyProgress.value = null
|
||||||
addLog(`Ontology generated successfully for project ${res.data.project_id}`)
|
addLog(`Ontology ready for project ${res.data.project_id}. Waiting for confirmation to build GraphRAG.`)
|
||||||
await startBuildGraph()
|
ontologyReady.value = true
|
||||||
|
// Do NOT auto-start build — user must click "Proceed to GraphRAG"
|
||||||
} else {
|
} else {
|
||||||
error.value = res.error || 'Ontology generation failed'
|
error.value = res.error || 'Ontology step failed'
|
||||||
addLog(`Error generating ontology: ${error.value}`)
|
addLog(`Error: ${error.value}`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message
|
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(() => {
|
onMounted(() => {
|
||||||
initProject()
|
initProject()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@
|
||||||
"promptPlaceholder": "// Descriu el teu requisit de simulació o predicció en llenguatge natural",
|
"promptPlaceholder": "// Descriu el teu requisit de simulació o predicció en llenguatge natural",
|
||||||
"engineBadge": "Motor: MiroFish-V1.0",
|
"engineBadge": "Motor: MiroFish-V1.0",
|
||||||
"startEngine": "Inicia el motor",
|
"startEngine": "Inicia el motor",
|
||||||
"initializing": "Inicialitzant..."
|
"initializing": "Inicialitzant...",
|
||||||
|
"importOntology": "Importa ontologia",
|
||||||
|
"importOntologyHint": "Selecciona el fitxer JSON d'ontologia"
|
||||||
},
|
},
|
||||||
"main": {
|
"main": {
|
||||||
"layoutGraph": "Graf",
|
"layoutGraph": "Graf",
|
||||||
|
|
@ -114,7 +116,13 @@
|
||||||
"labelRelation": "RELACIÓ",
|
"labelRelation": "RELACIÓ",
|
||||||
"labelAttributes": "ATRIBUTS",
|
"labelAttributes": "ATRIBUTS",
|
||||||
"labelExamples": "EXEMPLES",
|
"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": {
|
"step2": {
|
||||||
"simInstanceInit": "Inicialització de la instància de simulació",
|
"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.",
|
"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",
|
"textNotFound": "No s'ha trobat el contingut de text extret",
|
||||||
"ontologyNotFound": "No s'ha trobat la definició de l'ontologia",
|
"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}.",
|
"graphBuildStarted": "Tasca de construcció del graf iniciada. Consulta el progrés a /task/{taskId}.",
|
||||||
"graphBuildComplete": "Construcció del graf completada",
|
"graphBuildComplete": "Construcció del graf completada",
|
||||||
"buildFailed": "Error en la construcció: {error}",
|
"buildFailed": "Error en la construcció: {error}",
|
||||||
|
|
@ -543,6 +554,9 @@
|
||||||
"loadConfigFailed": "Error en carregar la configuració: {error}",
|
"loadConfigFailed": "Error en carregar la configuració: {error}",
|
||||||
"step2Init": "Configuració de l'entorn del pas 2 inicialitzada",
|
"step2Init": "Configuració de l'entorn del pas 2 inicialitzada",
|
||||||
"step3Init": "Execució de la simulació del pas 3 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...",
|
"startingDualSim": "Iniciant la simulació paral·lela en dues plataformes...",
|
||||||
"setMaxRounds": "Nombre màxim de rondes de simulació establert a: {rounds}",
|
"setMaxRounds": "Nombre màxim de rondes de simulació establert a: {rounds}",
|
||||||
"graphMemoryUpdateEnabled": "Actualització dinàmica de la memòria del graf activada",
|
"graphMemoryUpdateEnabled": "Actualització dinàmica de la memòria del graf activada",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@
|
||||||
"promptPlaceholder": "// Describe your simulation or prediction requirement in natural language",
|
"promptPlaceholder": "// Describe your simulation or prediction requirement in natural language",
|
||||||
"engineBadge": "Engine: MiroFish-V1.0",
|
"engineBadge": "Engine: MiroFish-V1.0",
|
||||||
"startEngine": "Start Engine",
|
"startEngine": "Start Engine",
|
||||||
"initializing": "Initializing..."
|
"initializing": "Initializing...",
|
||||||
|
"importOntology": "Import Ontology",
|
||||||
|
"importOntologyHint": "Select ontology JSON file"
|
||||||
},
|
},
|
||||||
"main": {
|
"main": {
|
||||||
"layoutGraph": "Graph",
|
"layoutGraph": "Graph",
|
||||||
|
|
@ -114,7 +116,13 @@
|
||||||
"labelRelation": "RELATION",
|
"labelRelation": "RELATION",
|
||||||
"labelAttributes": "ATTRIBUTES",
|
"labelAttributes": "ATTRIBUTES",
|
||||||
"labelExamples": "EXAMPLES",
|
"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": {
|
"step2": {
|
||||||
"simInstanceInit": "Simulation Instance Initialization",
|
"simInstanceInit": "Simulation Instance Initialization",
|
||||||
|
|
@ -353,6 +361,9 @@
|
||||||
"graphBuilding": "Graph build in progress. Do not resubmit. To force rebuild, add force: true.",
|
"graphBuilding": "Graph build in progress. Do not resubmit. To force rebuild, add force: true.",
|
||||||
"textNotFound": "Extracted text content not found",
|
"textNotFound": "Extracted text content not found",
|
||||||
"ontologyNotFound": "Ontology definition 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}.",
|
"graphBuildStarted": "Graph build task started. Query progress via /task/{taskId}.",
|
||||||
"graphBuildComplete": "Graph build complete",
|
"graphBuildComplete": "Graph build complete",
|
||||||
"buildFailed": "Build failed: {error}",
|
"buildFailed": "Build failed: {error}",
|
||||||
|
|
@ -544,6 +555,9 @@
|
||||||
"loadConfigFailed": "Failed to load config: {error}",
|
"loadConfigFailed": "Failed to load config: {error}",
|
||||||
"step2Init": "Step 2 environment setup initialized",
|
"step2Init": "Step 2 environment setup initialized",
|
||||||
"step3Init": "Step 3 simulation run 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...",
|
"startingDualSim": "Starting dual-platform parallel simulation...",
|
||||||
"setMaxRounds": "Max simulation rounds set to: {rounds}",
|
"setMaxRounds": "Max simulation rounds set to: {rounds}",
|
||||||
"graphMemoryUpdateEnabled": "Dynamic graph memory update enabled",
|
"graphMemoryUpdateEnabled": "Dynamic graph memory update enabled",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@
|
||||||
"promptPlaceholder": "// Describe tu requisito de simulación o predicción en lenguaje natural",
|
"promptPlaceholder": "// Describe tu requisito de simulación o predicción en lenguaje natural",
|
||||||
"engineBadge": "Motor: MiroFish-V1.0",
|
"engineBadge": "Motor: MiroFish-V1.0",
|
||||||
"startEngine": "Iniciar motor",
|
"startEngine": "Iniciar motor",
|
||||||
"initializing": "Inicializando..."
|
"initializing": "Inicializando...",
|
||||||
|
"importOntology": "Importar ontología",
|
||||||
|
"importOntologyHint": "Seleccionar archivo JSON de ontología"
|
||||||
},
|
},
|
||||||
"main": {
|
"main": {
|
||||||
"layoutGraph": "Grafo",
|
"layoutGraph": "Grafo",
|
||||||
|
|
@ -114,7 +116,13 @@
|
||||||
"labelRelation": "RELACIÓN",
|
"labelRelation": "RELACIÓN",
|
||||||
"labelAttributes": "ATRIBUTOS",
|
"labelAttributes": "ATRIBUTOS",
|
||||||
"labelExamples": "EJEMPLOS",
|
"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": {
|
"step2": {
|
||||||
"simInstanceInit": "Inicialización de instancia de simulación",
|
"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.",
|
"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",
|
"textNotFound": "Contenido de texto extraído no encontrado",
|
||||||
"ontologyNotFound": "Definición de ontología no encontrada",
|
"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}.",
|
"graphBuildStarted": "Tarea de construcción del grafo iniciada. Consultá progreso vía /task/{taskId}.",
|
||||||
"graphBuildComplete": "Construcción del grafo completa",
|
"graphBuildComplete": "Construcción del grafo completa",
|
||||||
"buildFailed": "Construcción falló: {error}",
|
"buildFailed": "Construcción falló: {error}",
|
||||||
|
|
@ -543,6 +554,9 @@
|
||||||
"loadConfigFailed": "Error al cargar configuración: {error}",
|
"loadConfigFailed": "Error al cargar configuración: {error}",
|
||||||
"step2Init": "Paso 2: configuración del entorno inicializada",
|
"step2Init": "Paso 2: configuración del entorno inicializada",
|
||||||
"step3Init": "Paso 3: ejecución de simulación 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...",
|
"startingDualSim": "Iniciando simulación paralela de doble plataforma...",
|
||||||
"setMaxRounds": "Rondas máximas de simulación configuradas en: {rounds}",
|
"setMaxRounds": "Rondas máximas de simulación configuradas en: {rounds}",
|
||||||
"graphMemoryUpdateEnabled": "Actualización dinámica de memoria del grafo habilitada",
|
"graphMemoryUpdateEnabled": "Actualización dinámica de memoria del grafo habilitada",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@
|
||||||
"promptPlaceholder": "// 用自然语言输入模拟或预测需求(例.武大若发布撤销肖某处分的公告,会引发什么舆情走向)",
|
"promptPlaceholder": "// 用自然语言输入模拟或预测需求(例.武大若发布撤销肖某处分的公告,会引发什么舆情走向)",
|
||||||
"engineBadge": "引擎: MiroFish-V1.0",
|
"engineBadge": "引擎: MiroFish-V1.0",
|
||||||
"startEngine": "启动引擎",
|
"startEngine": "启动引擎",
|
||||||
"initializing": "初始化中..."
|
"initializing": "初始化中...",
|
||||||
|
"importOntology": "导入本体",
|
||||||
|
"importOntologyHint": "选择本体JSON文件"
|
||||||
},
|
},
|
||||||
"main": {
|
"main": {
|
||||||
"layoutGraph": "图谱",
|
"layoutGraph": "图谱",
|
||||||
|
|
@ -114,7 +116,13 @@
|
||||||
"labelRelation": "关系",
|
"labelRelation": "关系",
|
||||||
"labelAttributes": "属性",
|
"labelAttributes": "属性",
|
||||||
"labelExamples": "示例",
|
"labelExamples": "示例",
|
||||||
"labelConnections": "连接"
|
"labelConnections": "连接",
|
||||||
|
"pauseTitle": "本体已就绪",
|
||||||
|
"pauseDesc": "在构建知识图谱前,请检查生成的本体。",
|
||||||
|
"downloadOntology": "下载本体JSON",
|
||||||
|
"deleteOntology": "删除本体",
|
||||||
|
"proceedToGraph": "继续构建GraphRAG →",
|
||||||
|
"deleteOntologyConfirm": "删除此本体并返回首页?"
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
"simInstanceInit": "模拟实例初始化",
|
"simInstanceInit": "模拟实例初始化",
|
||||||
|
|
@ -353,6 +361,9 @@
|
||||||
"graphBuilding": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true",
|
"graphBuilding": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true",
|
||||||
"textNotFound": "未找到提取的文本内容",
|
"textNotFound": "未找到提取的文本内容",
|
||||||
"ontologyNotFound": "未找到本体定义",
|
"ontologyNotFound": "未找到本体定义",
|
||||||
|
"requireOntologyJson": "请提供本体JSON(ontology字段)",
|
||||||
|
"invalidOntologyJson": "本体JSON格式无效",
|
||||||
|
"invalidOntologyStructure": "本体必须包含entity_types和edge_types数组",
|
||||||
"graphBuildStarted": "图谱构建任务已启动,请通过 /task/{taskId} 查询进度",
|
"graphBuildStarted": "图谱构建任务已启动,请通过 /task/{taskId} 查询进度",
|
||||||
"graphBuildComplete": "图谱构建完成",
|
"graphBuildComplete": "图谱构建完成",
|
||||||
"buildFailed": "构建失败: {error}",
|
"buildFailed": "构建失败: {error}",
|
||||||
|
|
@ -544,6 +555,9 @@
|
||||||
"loadConfigFailed": "加载配置失败: {error}",
|
"loadConfigFailed": "加载配置失败: {error}",
|
||||||
"step2Init": "Step2 环境搭建初始化",
|
"step2Init": "Step2 环境搭建初始化",
|
||||||
"step3Init": "Step3 模拟运行初始化",
|
"step3Init": "Step3 模拟运行初始化",
|
||||||
|
"reconnectingToSim": "正在重新连接到运行中的模拟...",
|
||||||
|
"simAlreadyRunning": "模拟已在运行,正在重新连接...",
|
||||||
|
"simAlreadyCompleted": "模拟已完成",
|
||||||
"startingDualSim": "正在启动双平台并行模拟...",
|
"startingDualSim": "正在启动双平台并行模拟...",
|
||||||
"setMaxRounds": "设置最大模拟轮数: {rounds}",
|
"setMaxRounds": "设置最大模拟轮数: {rounds}",
|
||||||
"graphMemoryUpdateEnabled": "已开启动态图谱更新模式",
|
"graphMemoryUpdateEnabled": "已开启动态图谱更新模式",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue