diff --git a/backend/app/models/prompts.py b/backend/app/models/prompts.py index e6d6f54..b90d488 100644 --- a/backend/app/models/prompts.py +++ b/backend/app/models/prompts.py @@ -27,3 +27,41 @@ class PromptBatchUpdateRequest(BaseModel): class ResetToDefaultsRequest(BaseModel): step: str | None = None + + +# ── Export / Import models ───────────────────────────────────────────────── + + +class ProfileExportRequest(BaseModel): + format: str = "legco-reranker-profile/v1" + profile_name: str + exported_at: str | None = None + prompts: dict[str, str] + + +class ProfileExportResponse(BaseModel): + format: str + profile_name: str + exported_at: str + prompts: dict[str, str] + + +class AllProfilesExportResponse(BaseModel): + format: str + exported_at: str + active_profile: str + profiles: dict[str, dict] + + +class ProfileImportRequest(BaseModel): + format: str + profile_name: str + exported_at: str | None = None + prompts: dict[str, str] + + +class ProfileImportResponse(BaseModel): + status: str + profile: str + imported_steps: int + source_profile: str diff --git a/backend/app/routers/prompts.py b/backend/app/routers/prompts.py index 96a56de..6ef3555 100644 --- a/backend/app/routers/prompts.py +++ b/backend/app/routers/prompts.py @@ -1,6 +1,8 @@ import logging +from datetime import datetime, timezone from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse from app.core.dependencies import get_prompt_service from app.models.prompts import ( @@ -10,6 +12,10 @@ from app.models.prompts import ( PromptUpdateRequest, PromptBatchUpdateRequest, ResetToDefaultsRequest, + ProfileExportResponse, + AllProfilesExportResponse, + ProfileImportRequest, + ProfileImportResponse, ) logger = logging.getLogger(__name__) @@ -38,6 +44,63 @@ def _ensure_valid_step(step: str) -> None: raise HTTPException(status_code=400, detail=f"Invalid step '{step}'. Must be one of decompose, filter, generate.") +_EXPORT_FORMAT = "legco-reranker-profile/v1" + + +@router.get("/export/all", response_model=AllProfilesExportResponse) +def export_all_profiles(): + svc = get_prompt_service() + profiles_list = svc.list_profiles() + active_profile = svc.get_active_profile_name() + profiles_data: dict[str, dict] = {} + for p in profiles_list: + profiles_data[p["name"]] = {"prompts": svc.get_profile_prompts(p["name"])} + return AllProfilesExportResponse( + format=_EXPORT_FORMAT, + exported_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + active_profile=active_profile, + profiles=profiles_data, + ) + + +@router.get("/profiles/{name}/export", response_model=ProfileExportResponse) +def export_profile(name: str): + _ensure_valid_name(name) + svc = get_prompt_service() + prompts = svc.get_profile_prompts(name) + return JSONResponse( + content=ProfileExportResponse( + format=_EXPORT_FORMAT, + profile_name=name, + exported_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + prompts=prompts, + ).model_dump(), + headers={"Content-Disposition": f'attachment; filename="legco-profile-{name}.json"'}, + ) + + +@router.post("/profiles/{name}/import", response_model=ProfileImportResponse) +def import_profile(name: str, body: ProfileImportRequest): + _ensure_valid_name(name) + if body.format != _EXPORT_FORMAT: + raise HTTPException(status_code=400, detail=f"Unsupported format '{body.format}'. Expected '{_EXPORT_FORMAT}'.") + provided = set(body.prompts.keys()) + missing = _VALID_STEPS - provided + if missing: + raise HTTPException(status_code=400, detail=f"Missing required steps: {', '.join(sorted(missing))}") + unknown = provided - _VALID_STEPS + if unknown: + raise HTTPException(status_code=400, detail=f"Unknown steps: {', '.join(sorted(unknown))}") + svc = get_prompt_service() + svc.update_all_prompts(name, body.prompts) + return ProfileImportResponse( + status="ok", + profile=name, + imported_steps=len(body.prompts), + source_profile=body.profile_name, + ) + + @router.get("/profiles", response_model=ProfileListResponse) def list_profiles(): svc = get_prompt_service() diff --git a/backend/app/test/test_phaseX_export.py b/backend/app/test/test_phaseX_export.py new file mode 100644 index 0000000..b3e56ca --- /dev/null +++ b/backend/app/test/test_phaseX_export.py @@ -0,0 +1,88 @@ +"""Tests for prompt profile export endpoints (Phase PX.1). + +Covers: +- GET /api/v1/prompts/profiles/{name}/export — single profile export +- GET /api/v1/prompts/export/all — all profiles export +- Validation of profile name, format, and structure +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.core.sqlite_db import init_prompts_db, seed_default_profiles, _get_db +from app.routers.prompts import router + + +_VALID_STEPS = { + "decompose", "filter", "generate", + "generate_per_subq", "filter_intro", "filter_section", "filter_outro", +} + + +@pytest.fixture +def client(tmp_path, monkeypatch): + prompts_path = str(tmp_path / "prompts.db") + monkeypatch.setenv("PROMPTS_DB_PATH", prompts_path) + monkeypatch.setenv("HISTORY_DB_PATH", str(tmp_path / "history.db")) + + from app.core.config import get_settings + get_settings.cache_clear() + from app.core.dependencies import get_settings_cached + get_settings_cached.cache_clear() + + conn = _get_db(prompts_path) + init_prompts_db(conn) + seed_default_profiles(conn) + conn.close() + + test_app = FastAPI() + test_app.include_router(router) + + yield TestClient(test_app) + + get_settings_cached.cache_clear() + get_settings.cache_clear() + + +def test_export_profile_valid(client): + resp = client.get("/api/v1/prompts/profiles/A/export") + assert resp.status_code == 200 + + data = resp.json() + assert data["format"] == "legco-reranker-profile/v1" + assert data["profile_name"] == "A" + assert "exported_at" in data + assert set(data["prompts"].keys()) == _VALID_STEPS + + +def test_export_profile_has_content_disposition_header(client): + resp = client.get("/api/v1/prompts/profiles/A/export") + assert resp.status_code == 200 + assert resp.headers["content-disposition"] == 'attachment; filename="legco-profile-A.json"' + + +def test_export_profile_invalid_name(client): + resp = client.get("/api/v1/prompts/profiles/X/export") + assert resp.status_code == 400 + + +def test_export_all(client): + resp = client.get("/api/v1/prompts/export/all") + assert resp.status_code == 200 + + data = resp.json() + assert data["format"] == "legco-reranker-profile/v1" + assert "exported_at" in data + assert data["active_profile"] == "A" + assert set(data["profiles"].keys()) == {"A", "B", "C"} + + for name in ("A", "B", "C"): + assert set(data["profiles"][name]["prompts"].keys()) == _VALID_STEPS + + +def test_export_profile_b_and_c(client): + for name in ("B", "C"): + resp = client.get(f"/api/v1/prompts/profiles/{name}/export") + assert resp.status_code == 200 + assert resp.json()["profile_name"] == name diff --git a/backend/app/test/test_phaseX_import.py b/backend/app/test/test_phaseX_import.py new file mode 100644 index 0000000..30da0e6 --- /dev/null +++ b/backend/app/test/test_phaseX_import.py @@ -0,0 +1,126 @@ +"""Tests for prompt profile import endpoint (Phase PX.2). + +Covers: +- POST /api/v1/prompts/profiles/{name}/import — import with validation +- Format validation, step validation, profile name validation +- Import overwrites existing prompts, does not change active profile +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.core.sqlite_db import init_prompts_db, seed_default_profiles, _get_db +from app.routers.prompts import router + + +_VALID_STEPS = { + "decompose", "filter", "generate", + "generate_per_subq", "filter_intro", "filter_section", "filter_outro", +} + +_IMPORT_PAYLOAD = { + "format": "legco-reranker-profile/v1", + "profile_name": "A", + "prompts": { + "decompose": "imported decompose", + "filter": "imported filter", + "generate": "imported generate", + "generate_per_subq": "imported generate_per_subq", + "filter_intro": "imported filter_intro", + "filter_section": "imported filter_section", + "filter_outro": "imported filter_outro", + }, +} + + +@pytest.fixture +def client(tmp_path, monkeypatch): + prompts_path = str(tmp_path / "prompts.db") + monkeypatch.setenv("PROMPTS_DB_PATH", prompts_path) + monkeypatch.setenv("HISTORY_DB_PATH", str(tmp_path / "history.db")) + + from app.core.config import get_settings + get_settings.cache_clear() + from app.core.dependencies import get_settings_cached + get_settings_cached.cache_clear() + + conn = _get_db(prompts_path) + init_prompts_db(conn) + seed_default_profiles(conn) + conn.close() + + test_app = FastAPI() + test_app.include_router(router) + + yield TestClient(test_app) + + get_settings_cached.cache_clear() + get_settings.cache_clear() + + +def test_import_valid(client): + resp = client.post("/api/v1/prompts/profiles/B/import", json=_IMPORT_PAYLOAD) + assert resp.status_code == 200 + + data = resp.json() + assert data["status"] == "ok" + assert data["profile"] == "B" + assert data["imported_steps"] == 7 + assert data["source_profile"] == "A" + + +def test_import_missing_step(client): + payload = { + "format": "legco-reranker-profile/v1", + "profile_name": "A", + "prompts": {k: "x" for k in list(_VALID_STEPS) if k != "decompose"}, + } + resp = client.post("/api/v1/prompts/profiles/B/import", json=payload) + assert resp.status_code == 400 + assert "decompose" in resp.json()["detail"] + + +def test_import_unknown_step(client): + payload = { + "format": "legco-reranker-profile/v1", + "profile_name": "A", + "prompts": {**{k: "x" for k in _VALID_STEPS}, "extra_step": "x"}, + } + resp = client.post("/api/v1/prompts/profiles/B/import", json=payload) + assert resp.status_code == 400 + assert "extra_step" in resp.json()["detail"] + + +def test_import_invalid_format(client): + payload = {**_IMPORT_PAYLOAD, "format": "v999"} + resp = client.post("/api/v1/prompts/profiles/B/import", json=payload) + assert resp.status_code == 400 + assert "v999" in resp.json()["detail"] + + +def test_import_invalid_target(client): + resp = client.post("/api/v1/prompts/profiles/X/import", json=_IMPORT_PAYLOAD) + assert resp.status_code == 400 + + +def test_import_overwrites_existing(client): + resp = client.get("/api/v1/prompts/profiles/B") + original = resp.json()["prompts"]["decompose"] + + client.post("/api/v1/prompts/profiles/B/import", json=_IMPORT_PAYLOAD) + + resp = client.get("/api/v1/prompts/profiles/B") + assert resp.json()["prompts"]["decompose"] == "imported decompose" + assert resp.json()["prompts"]["decompose"] != original + + +def test_import_does_not_change_active(client): + resp = client.get("/api/v1/prompts/profiles") + active_before = {p["name"]: p["is_active"] for p in resp.json()["profiles"]} + + client.post("/api/v1/prompts/profiles/B/import", json=_IMPORT_PAYLOAD) + + resp = client.get("/api/v1/prompts/profiles") + active_after = {p["name"]: p["is_active"] for p in resp.json()["profiles"]} + assert active_before == active_after diff --git a/frontend/src/components/ProfileList.tsx b/frontend/src/components/ProfileList.tsx index ffd0ade..dc39f14 100644 --- a/frontend/src/components/ProfileList.tsx +++ b/frontend/src/components/ProfileList.tsx @@ -1,14 +1,15 @@ import React from 'react' -import { Pencil } from 'lucide-react' +import { Pencil, Download } from 'lucide-react' import type { PromptProfile } from '../types' export interface ProfileListProps { profiles: PromptProfile[] selectedProfile: string | null onSelect: (name: string) => void + onExport?: (name: string) => void } -export const ProfileList: React.FC = ({ profiles, selectedProfile, onSelect }) => { +export const ProfileList: React.FC = ({ profiles, selectedProfile, onSelect, onExport }) => { return (
{profiles.map((profile) => { @@ -43,18 +44,30 @@ export const ProfileList: React.FC = ({ profiles, selectedProf )}
- +
+ {onExport && ( + + )} + +
) })} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 79b9e96..673c4a9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' +import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, ProfileExportData, ProfileImportResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1' @@ -119,6 +119,16 @@ export const resetPrompts = async (name: string, step?: string): Promise => { + const resp = await apiClient.get(`/prompts/profiles/${name}/export`) + return resp.data +} + +export const importProfile = async (name: string, data: ProfileExportData): Promise => { + const resp = await apiClient.post(`/prompts/profiles/${name}/import`, data) + return resp.data +} + export const listQueryHistory = async (limit = 50, offset = 0): Promise => { const resp = await apiClient.get(`/history?limit=${limit}&offset=${offset}`) return resp.data diff --git a/frontend/src/lib/queries.tsx b/frontend/src/lib/queries.tsx index ae6cfd0..c3e8143 100644 --- a/frontend/src/lib/queries.tsx +++ b/frontend/src/lib/queries.tsx @@ -1,7 +1,7 @@ import React from 'react' import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts, listQueryHistory, getQueryHistoryDetail, deleteQueryHistory, clearQueryHistory, getHistoryStats } from './api' -import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, SubQuestionSources, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' +import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts, exportProfile, importProfile, listQueryHistory, getQueryHistoryDetail, deleteQueryHistory, clearQueryHistory, getHistoryStats } from './api' +import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, SubQuestionSources, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, ProfileExportData, ProfileImportResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' import { useState, useCallback, useRef } from 'react' export const queryClient = new QueryClient() @@ -208,6 +208,16 @@ export const useResetPrompts = () => { }) } +export const useImportProfile = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ name, data }) => importProfile(name, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['prompts'] }) + }, + }) +} + export const useQueryHistoryList = (limit = 50, offset = 0) => { return useQuery({ queryKey: ['history', { limit, offset }], diff --git a/frontend/src/pages/SystemPromptsPage.tsx b/frontend/src/pages/SystemPromptsPage.tsx index 5085d69..5ed10d4 100644 --- a/frontend/src/pages/SystemPromptsPage.tsx +++ b/frontend/src/pages/SystemPromptsPage.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' -import { AlertCircle, RotateCcw } from 'lucide-react' +import React, { useState, useRef } from 'react' +import { AlertCircle, RotateCcw, Upload, Download } from 'lucide-react' import { useQueryClient } from '@tanstack/react-query' import { usePromptProfiles, @@ -7,11 +7,13 @@ import { useActivateProfile, useUpdateAllPrompts, useResetPrompts, + useImportProfile, } from '../lib/queries' +import { exportProfile } from '../lib/api' import { ProfileList } from '../components/ProfileList' import { PromptEditor } from '../components/PromptEditor' import { PlaceholderDocs } from '../components/PlaceholderDocs' -import type { PromptProfile } from '../types' +import type { PromptProfile, ProfileExportData } from '../types' export const SystemPromptsPage: React.FC = () => { const [selectedProfile, setSelectedProfile] = useState(null) @@ -19,6 +21,13 @@ export const SystemPromptsPage: React.FC = () => { const [editPrompts, setEditPrompts] = useState>({}) const [hasChanges, setHasChanges] = useState(false) + const [showImportDialog, setShowImportDialog] = useState(false) + const [importFile, setImportFile] = useState(null) + const [importData, setImportData] = useState(null) + const [importError, setImportError] = useState(null) + const [importTargetProfile, setImportTargetProfile] = useState('') + const fileInputRef = useRef(null) + const queryClient = useQueryClient() const { @@ -35,6 +44,7 @@ export const SystemPromptsPage: React.FC = () => { const activateMutation = useActivateProfile() const updateAllMutation = useUpdateAllPrompts() const resetMutation = useResetPrompts() + const importMutation = useImportProfile() const profiles: PromptProfile[] = profilesData?.profiles ?? [] const activeProfile = profiles.find((p) => p.is_active)?.name ?? '' @@ -121,6 +131,88 @@ export const SystemPromptsPage: React.FC = () => { setHasChanges(false) } + const handleExport = async (name: string) => { + try { + const data = await exportProfile(name) + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + const date = new Date().toISOString().split('T')[0] + a.href = url + a.download = `legco-profile-${name}-${date}.json` + a.click() + URL.revokeObjectURL(url) + } catch (err) { + console.error('Export failed:', err) + } + } + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null + setImportFile(file) + setImportData(null) + setImportError(null) + setImportTargetProfile('') + + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + try { + const text = String(event.target?.result ?? '') + const parsed = JSON.parse(text) as unknown + + if ( + typeof parsed === 'object' && + parsed !== null && + 'format' in parsed && + 'profile_name' in parsed && + 'exported_at' in parsed && + 'prompts' in parsed && + typeof (parsed as Record).prompts === 'object' + ) { + const data = parsed as ProfileExportData + setImportData(data) + const profileNames = profiles.map((p) => p.name) + if (profileNames.includes(data.profile_name)) { + setImportTargetProfile(data.profile_name) + } else if (profileNames.length > 0) { + setImportTargetProfile(profileNames[0]) + } + } else { + setImportError('Invalid profile export file format.') + } + } catch { + setImportError('Failed to parse JSON file.') + } + } + reader.readAsText(file) + } + + const handleImport = () => { + if (!importTargetProfile || !importData) return + importMutation.mutate( + { name: importTargetProfile, data: importData }, + { + onSuccess: () => { + setShowImportDialog(false) + setImportFile(null) + setImportData(null) + setImportError(null) + setImportTargetProfile('') + }, + } + ) + } + + const handleCloseImportDialog = () => { + setShowImportDialog(false) + setImportFile(null) + setImportData(null) + setImportError(null) + setImportTargetProfile('') + } + const isSaving = updateAllMutation.isPending || resetMutation.isPending || activateMutation.isPending if (isLoadingProfiles) { @@ -195,12 +287,27 @@ export const SystemPromptsPage: React.FC = () => { > {activateMutation.isPending ? 'Setting...' : 'Set Active'} + {selectedProfile && ( @@ -236,6 +343,102 @@ export const SystemPromptsPage: React.FC = () => { )} + + {showImportDialog && ( +
{ + if (e.target === e.currentTarget) handleCloseImportDialog() + }} + > +
+

Import Profile

+ + {importError && ( +
+ + {importError} +
+ )} + + {!importData ? ( +
fileInputRef.current?.click()} + > + +

Click to select a JSON file

+ +
+ ) : ( +
+
+
+ Source Profile: + {importData.profile_name} +
+
+ Exported At: + {importData.exported_at} +
+
+ Steps: + + {Object.keys(importData.prompts).length} + +
+
+ +
+ + +
+ +
+ This will overwrite all prompts for Profile {importTargetProfile || '...'}. +
+ +
+ + +
+
+ )} +
+
+ )} ) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3e9cf99..f63dc05 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -99,6 +99,20 @@ export interface PromptStatusResponse { reset_step?: string } +export interface ProfileExportData { + format: string + profile_name: string + exported_at: string + prompts: Record +} + +export interface ProfileImportResponse { + status: string + profile: string + imported_steps: number + source_profile: string +} + export interface QueryHistorySummary { id: number input_text: string