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.
@@ -0,0 +1,3 @@
1
+ """Local screenplay labeling with side-by-side change traces."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from scripttrace.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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