scripttrace 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scripttrace/__init__.py +3 -0
- scripttrace/__main__.py +4 -0
- scripttrace/cli.py +91 -0
- scripttrace/diff.py +29 -0
- scripttrace/parse.py +148 -0
- scripttrace/server.py +176 -0
- scripttrace/static/app.js +971 -0
- scripttrace/static/index.html +110 -0
- scripttrace/static/styles.css +745 -0
- scripttrace/store.py +322 -0
- scripttrace-0.1.0.dist-info/METADATA +323 -0
- scripttrace-0.1.0.dist-info/RECORD +16 -0
- scripttrace-0.1.0.dist-info/WHEEL +5 -0
- scripttrace-0.1.0.dist-info/entry_points.txt +2 -0
- scripttrace-0.1.0.dist-info/licenses/LICENSE +21 -0
- scripttrace-0.1.0.dist-info/top_level.txt +1 -0
scripttrace/__init__.py
ADDED
scripttrace/__main__.py
ADDED
scripttrace/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
10
|
+
DEFAULT_PORT = 8765
|
|
11
|
+
|
|
12
|
+
_EPILOG = """
|
|
13
|
+
示例:
|
|
14
|
+
scripttrace serve
|
|
15
|
+
scripttrace serve --port 9000
|
|
16
|
+
scripttrace serve -p 9000 --data-dir D:\\labels
|
|
17
|
+
scripttrace serve --help
|
|
18
|
+
""".strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _port_type(value: str) -> int:
|
|
22
|
+
try:
|
|
23
|
+
port = int(value)
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
raise argparse.ArgumentTypeError("端口必须是整数") from exc
|
|
26
|
+
if not 1 <= port <= 65535:
|
|
27
|
+
raise argparse.ArgumentTypeError("端口必须在 1–65535")
|
|
28
|
+
return port
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="scripttrace",
|
|
34
|
+
description="本地剧本对照标注:上传 Markdown/Word,左右对照改稿并导出 JSON。原文只读。",
|
|
35
|
+
epilog=_EPILOG,
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
37
|
+
)
|
|
38
|
+
sub = parser.add_subparsers(dest="cmd", required=True, metavar="命令")
|
|
39
|
+
|
|
40
|
+
serve = sub.add_parser(
|
|
41
|
+
"serve",
|
|
42
|
+
help="启动本地网页(可用 --port 改端口)",
|
|
43
|
+
description="启动本地对照标注网页。默认地址 http://127.0.0.1:8765",
|
|
44
|
+
epilog=(
|
|
45
|
+
"示例:\n"
|
|
46
|
+
" scripttrace serve\n"
|
|
47
|
+
" scripttrace serve --port 9000\n"
|
|
48
|
+
" scripttrace serve -p 9000 --host 0.0.0.0 --data-dir D:\\labels"
|
|
49
|
+
),
|
|
50
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
51
|
+
)
|
|
52
|
+
serve.add_argument(
|
|
53
|
+
"--host",
|
|
54
|
+
default=os.environ.get("SCRIPTTRACE_HOST", DEFAULT_HOST),
|
|
55
|
+
help="监听地址,默认 %(default)s(本机)。局域网访问可用 0.0.0.0。"
|
|
56
|
+
"也可用环境变量 SCRIPTTRACE_HOST。",
|
|
57
|
+
)
|
|
58
|
+
serve.add_argument(
|
|
59
|
+
"-p",
|
|
60
|
+
"--port",
|
|
61
|
+
type=_port_type,
|
|
62
|
+
default=_port_type(os.environ.get("SCRIPTTRACE_PORT", str(DEFAULT_PORT))),
|
|
63
|
+
metavar="PORT",
|
|
64
|
+
help="监听端口,默认 %(default)s。例如: scripttrace serve --port 9000。"
|
|
65
|
+
"也可用环境变量 SCRIPTTRACE_PORT。",
|
|
66
|
+
)
|
|
67
|
+
serve.add_argument(
|
|
68
|
+
"--data-dir",
|
|
69
|
+
default=os.environ.get("SCRIPTTRACE_HOME"),
|
|
70
|
+
metavar="DIR",
|
|
71
|
+
help="项目数据目录,默认 ~/.scripttrace/projects。也可用环境变量 SCRIPTTRACE_HOME。",
|
|
72
|
+
)
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
parser = build_parser()
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
if args.cmd == "serve":
|
|
80
|
+
if args.data_dir:
|
|
81
|
+
os.environ["SCRIPTTRACE_HOME"] = str(Path(args.data_dir).expanduser())
|
|
82
|
+
url_host = "127.0.0.1" if args.host in {"0.0.0.0", "::"} else args.host
|
|
83
|
+
print(f"scripttrace 已启动:http://{url_host}:{args.port}", flush=True)
|
|
84
|
+
uvicorn.run(
|
|
85
|
+
"scripttrace.server:app",
|
|
86
|
+
host=args.host,
|
|
87
|
+
port=args.port,
|
|
88
|
+
reload=False,
|
|
89
|
+
)
|
|
90
|
+
return 0
|
|
91
|
+
return 1
|
scripttrace/diff.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
Op = Literal["equal", "delete", "insert"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def iter_ops(original: str, revised: str) -> list[dict]:
|
|
10
|
+
"""Character-level opcodes compatible with training export."""
|
|
11
|
+
matcher = difflib.SequenceMatcher(a=original, b=revised, autojunk=False)
|
|
12
|
+
ops: list[dict] = []
|
|
13
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
14
|
+
if tag == "equal":
|
|
15
|
+
ops.append({"op": "equal", "text": original[i1:i2]})
|
|
16
|
+
elif tag == "delete":
|
|
17
|
+
ops.append({"op": "delete", "text": original[i1:i2]})
|
|
18
|
+
elif tag == "insert":
|
|
19
|
+
ops.append({"op": "insert", "text": revised[j1:j2]})
|
|
20
|
+
elif tag == "replace":
|
|
21
|
+
if i1 != i2:
|
|
22
|
+
ops.append({"op": "delete", "text": original[i1:i2]})
|
|
23
|
+
if j1 != j2:
|
|
24
|
+
ops.append({"op": "insert", "text": revised[j1:j2]})
|
|
25
|
+
return ops
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def changed(original: str, revised: str) -> bool:
|
|
29
|
+
return original != revised
|
scripttrace/parse.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from docx import Document
|
|
7
|
+
|
|
8
|
+
_EPISODE_RE = re.compile(r"^(?:#{1,3}\s*)?(?:第\s*(\d+)\s*集|EPISODE\s+(\d+))\b", re.I)
|
|
9
|
+
_SCENE_RE = re.compile(
|
|
10
|
+
r"^(?:#{1,3}\s*|\*\*)?(?:场景|場次|SCENE)\s*[::]?\s*([^\s*]+)",
|
|
11
|
+
re.I,
|
|
12
|
+
)
|
|
13
|
+
_SCENE_HEADING_RE = re.compile(
|
|
14
|
+
r"^(?:\*\*)?(INT\.?/EXT\.?|INT\.?|EXT\.?|内景|外景|内\/外)",
|
|
15
|
+
re.I,
|
|
16
|
+
)
|
|
17
|
+
_MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
18
|
+
_BOLD_RE = re.compile(r"^\*\*(.+?)\*\*\s*$")
|
|
19
|
+
_SPEAKER_LINE_RE = re.compile(
|
|
20
|
+
r"^[*__]*([\u4e00-\u9fffA-Za-z0-9·•]{1,20})[*__]*\s*[::]\s*(.*)$"
|
|
21
|
+
)
|
|
22
|
+
_CUE_RE = re.compile(r"^[*__]*([\u4e00-\u9fffA-Za-z0-9·•]{1,20})[*__]*$")
|
|
23
|
+
_ACTION_RE = re.compile(r"^[△▲∆▲]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_text(path: Path) -> str:
|
|
27
|
+
suffix = path.suffix.lower()
|
|
28
|
+
if suffix in {".md", ".markdown", ".txt", ".fountain"}:
|
|
29
|
+
raw = path.read_bytes()
|
|
30
|
+
for enc in ("utf-8-sig", "utf-8", "gb18030"):
|
|
31
|
+
try:
|
|
32
|
+
return raw.decode(enc)
|
|
33
|
+
except UnicodeDecodeError:
|
|
34
|
+
continue
|
|
35
|
+
raise ValueError("无法识别文本编码,请另存为 UTF-8")
|
|
36
|
+
if suffix in {".docx"}:
|
|
37
|
+
doc = Document(str(path))
|
|
38
|
+
return "\n".join(p.text for p in doc.paragraphs)
|
|
39
|
+
raise ValueError(f"不支持的文件类型: {suffix}(请上传 .md / .txt / .docx)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_screenplay(text: str) -> list[dict]:
|
|
43
|
+
"""Split a screenplay into labeled blocks for side-by-side editing."""
|
|
44
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
45
|
+
blocks: list[dict] = []
|
|
46
|
+
episode = ""
|
|
47
|
+
scene = ""
|
|
48
|
+
pending_speaker: str | None = None
|
|
49
|
+
scene_index = 0
|
|
50
|
+
|
|
51
|
+
def add(kind: str, body: str, speaker: str | None = None) -> None:
|
|
52
|
+
nonlocal scene
|
|
53
|
+
body = body.strip("\n")
|
|
54
|
+
if body.strip() == "" and kind == "other":
|
|
55
|
+
return
|
|
56
|
+
sid = scene
|
|
57
|
+
if kind == "scene" and not sid:
|
|
58
|
+
sid = str(scene_index)
|
|
59
|
+
blocks.append(
|
|
60
|
+
{
|
|
61
|
+
"id": f"b{len(blocks) + 1:04d}",
|
|
62
|
+
"kind": kind,
|
|
63
|
+
"original": body,
|
|
64
|
+
"revised": body,
|
|
65
|
+
"speaker": speaker,
|
|
66
|
+
"episode": episode or None,
|
|
67
|
+
"scene": sid or None,
|
|
68
|
+
"note": "",
|
|
69
|
+
"score": None,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
i = 0
|
|
74
|
+
while i < len(lines):
|
|
75
|
+
raw = lines[i]
|
|
76
|
+
stripped = raw.strip()
|
|
77
|
+
if stripped in {"", "---", "***", "* * *"}:
|
|
78
|
+
pending_speaker = None
|
|
79
|
+
i += 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
ep = _EPISODE_RE.match(stripped.strip("*").strip())
|
|
83
|
+
if ep:
|
|
84
|
+
episode = ep.group(1) or ep.group(2)
|
|
85
|
+
scene = ""
|
|
86
|
+
pending_speaker = None
|
|
87
|
+
add("episode", stripped)
|
|
88
|
+
i += 1
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
sc = _SCENE_RE.match(stripped.strip("*").strip())
|
|
92
|
+
if sc or _SCENE_HEADING_RE.match(stripped.strip("*").strip()):
|
|
93
|
+
scene_index += 1
|
|
94
|
+
scene = (sc.group(1).strip() if sc else stripped.strip("*").strip())[:40]
|
|
95
|
+
pending_speaker = None
|
|
96
|
+
add("scene", stripped)
|
|
97
|
+
i += 1
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
heading = _MD_HEADING_RE.match(stripped)
|
|
101
|
+
if heading:
|
|
102
|
+
pending_speaker = None
|
|
103
|
+
add("heading", stripped)
|
|
104
|
+
i += 1
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
speaker_line = _SPEAKER_LINE_RE.match(stripped)
|
|
108
|
+
if speaker_line:
|
|
109
|
+
pending_speaker = None
|
|
110
|
+
add("dialogue", speaker_line.group(2).strip(), speaker_line.group(1))
|
|
111
|
+
i += 1
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
bold = _BOLD_RE.match(stripped)
|
|
115
|
+
cue = _CUE_RE.match(stripped.strip("*").strip())
|
|
116
|
+
looks_cue = False
|
|
117
|
+
name = None
|
|
118
|
+
if bold:
|
|
119
|
+
name = bold.group(1).strip()
|
|
120
|
+
looks_cue = 1 <= len(name) <= 20 and ":" not in name and ":" not in name
|
|
121
|
+
elif cue and not _ACTION_RE.match(stripped):
|
|
122
|
+
name = cue.group(1)
|
|
123
|
+
looks_cue = len(name) <= 16 and not stripped.endswith("。")
|
|
124
|
+
|
|
125
|
+
if looks_cue and name:
|
|
126
|
+
nxt = lines[i + 1].strip() if i + 1 < len(lines) else ""
|
|
127
|
+
if nxt and not _SPEAKER_LINE_RE.match(nxt) and not _MD_HEADING_RE.match(nxt):
|
|
128
|
+
pending_speaker = None
|
|
129
|
+
add("dialogue", nxt, name)
|
|
130
|
+
i += 2
|
|
131
|
+
continue
|
|
132
|
+
pending_speaker = name
|
|
133
|
+
i += 1
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if pending_speaker:
|
|
137
|
+
add("dialogue", stripped, pending_speaker)
|
|
138
|
+
pending_speaker = None
|
|
139
|
+
i += 1
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
kind = "action" if _ACTION_RE.match(stripped) else "other"
|
|
143
|
+
add(kind, stripped)
|
|
144
|
+
i += 1
|
|
145
|
+
|
|
146
|
+
if not blocks:
|
|
147
|
+
add("other", text.strip() or text)
|
|
148
|
+
return blocks
|
scripttrace/server.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
|
10
|
+
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from scripttrace.diff import iter_ops
|
|
15
|
+
from scripttrace.store import ProjectStore, default_root
|
|
16
|
+
|
|
17
|
+
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_store() -> ProjectStore:
|
|
21
|
+
home = os.environ.get("SCRIPTTRACE_HOME")
|
|
22
|
+
return ProjectStore(Path(home) if home else default_root())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def attachment_headers(filename: str, fallback: str) -> dict[str, str]:
|
|
26
|
+
"""Build a latin-1-safe Content-Disposition so Chinese titles can download."""
|
|
27
|
+
safe = (
|
|
28
|
+
filename.replace("\\", "_")
|
|
29
|
+
.replace("/", "_")
|
|
30
|
+
.replace('"', "")
|
|
31
|
+
.replace("\r", "")
|
|
32
|
+
.replace("\n", "")
|
|
33
|
+
)
|
|
34
|
+
ascii_name = "".join(ch if 32 <= ord(ch) < 127 else "_" for ch in safe)
|
|
35
|
+
ascii_name = ascii_name.strip("._") or fallback
|
|
36
|
+
return {
|
|
37
|
+
"Content-Disposition": (
|
|
38
|
+
f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quote(safe, safe="._-")}'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = FastAPI(title="scripttrace", version="0.1.0")
|
|
44
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SaveBody(BaseModel):
|
|
48
|
+
title: str | None = None
|
|
49
|
+
blocks: list[dict] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RenameBody(BaseModel):
|
|
53
|
+
title: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DiffBody(BaseModel):
|
|
57
|
+
original: str
|
|
58
|
+
revised: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.get("/")
|
|
62
|
+
def index() -> FileResponse:
|
|
63
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.get("/api/projects")
|
|
67
|
+
def list_projects() -> list[dict]:
|
|
68
|
+
return get_store().list_projects()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.post("/api/projects")
|
|
72
|
+
async def create_project(file: UploadFile = File(...)) -> JSONResponse:
|
|
73
|
+
name = file.filename or "upload.md"
|
|
74
|
+
suffix = Path(name).suffix.lower()
|
|
75
|
+
if suffix not in {".md", ".markdown", ".txt", ".docx", ".fountain"}:
|
|
76
|
+
raise HTTPException(400, "请上传 .md / .txt / .docx")
|
|
77
|
+
raw = await file.read()
|
|
78
|
+
if len(raw) > 20 * 1024 * 1024:
|
|
79
|
+
raise HTTPException(400, "文件超过 20MB")
|
|
80
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
81
|
+
tmp.write(raw)
|
|
82
|
+
tmp_path = Path(tmp.name)
|
|
83
|
+
try:
|
|
84
|
+
data = get_store().create_from_file(tmp_path, name)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise HTTPException(400, str(exc)) from exc
|
|
87
|
+
finally:
|
|
88
|
+
tmp_path.unlink(missing_ok=True)
|
|
89
|
+
return JSONResponse(data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.get("/api/projects/{project_id}")
|
|
93
|
+
def get_project(project_id: str) -> dict:
|
|
94
|
+
try:
|
|
95
|
+
return get_store().get(project_id)
|
|
96
|
+
except FileNotFoundError as exc:
|
|
97
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.put("/api/projects/{project_id}")
|
|
101
|
+
def save_project(project_id: str, body: SaveBody) -> dict:
|
|
102
|
+
try:
|
|
103
|
+
return get_store().save_blocks(project_id, body.blocks, title=body.title)
|
|
104
|
+
except FileNotFoundError as exc:
|
|
105
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.patch("/api/projects/{project_id}")
|
|
109
|
+
def rename_project(project_id: str, body: RenameBody) -> dict:
|
|
110
|
+
try:
|
|
111
|
+
return get_store().rename(project_id, body.title)
|
|
112
|
+
except FileNotFoundError as exc:
|
|
113
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.delete("/api/projects/{project_id}")
|
|
117
|
+
def delete_project(project_id: str) -> dict:
|
|
118
|
+
try:
|
|
119
|
+
get_store().delete(project_id)
|
|
120
|
+
except FileNotFoundError as exc:
|
|
121
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
122
|
+
return {"ok": True}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.post("/api/diff")
|
|
126
|
+
def diff_text(body: DiffBody) -> dict:
|
|
127
|
+
return {"ops": iter_ops(body.original, body.revised)}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.get("/api/projects/{project_id}/export.json")
|
|
131
|
+
def export_json(project_id: str) -> JSONResponse:
|
|
132
|
+
try:
|
|
133
|
+
payload = get_store().export_payload(project_id)
|
|
134
|
+
except FileNotFoundError as exc:
|
|
135
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
136
|
+
filename = f"{payload.get('title') or project_id}.scripttrace.json"
|
|
137
|
+
return JSONResponse(
|
|
138
|
+
payload,
|
|
139
|
+
headers=attachment_headers(filename, f"{project_id}.scripttrace.json"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.get("/api/projects/{project_id}/export.sft.jsonl")
|
|
144
|
+
def export_sft(project_id: str) -> PlainTextResponse:
|
|
145
|
+
try:
|
|
146
|
+
payload = get_store().export_payload(project_id)
|
|
147
|
+
except FileNotFoundError as exc:
|
|
148
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
149
|
+
lines = [json.dumps(row, ensure_ascii=False) for row in payload.get("sft") or []]
|
|
150
|
+
filename = f"{payload.get('title') or project_id}.sft.jsonl"
|
|
151
|
+
return PlainTextResponse(
|
|
152
|
+
"\n".join(lines) + ("\n" if lines else ""),
|
|
153
|
+
media_type="application/jsonl; charset=utf-8",
|
|
154
|
+
headers=attachment_headers(filename, f"{project_id}.sft.jsonl"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.get("/api/projects/{project_id}/export.trace.json")
|
|
159
|
+
def export_trace(project_id: str) -> JSONResponse:
|
|
160
|
+
try:
|
|
161
|
+
payload = get_store().export_trace(project_id)
|
|
162
|
+
except FileNotFoundError as exc:
|
|
163
|
+
raise HTTPException(404, "项目不存在") from exc
|
|
164
|
+
filename = f"{payload.get('title') or project_id}.trace.json"
|
|
165
|
+
return JSONResponse(
|
|
166
|
+
payload,
|
|
167
|
+
headers=attachment_headers(filename, f"{project_id}.trace.json"),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.get("/api/projects/{project_id}/preview")
|
|
172
|
+
def preview_project(project_id: str) -> dict:
|
|
173
|
+
try:
|
|
174
|
+
return get_store().preview_payload(project_id)
|
|
175
|
+
except FileNotFoundError as exc:
|
|
176
|
+
raise HTTPException(404, "项目不存在") from exc
|