qualitranslate 1.5.1__tar.gz

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.
Files changed (26) hide show
  1. qualitranslate-1.5.1/LICENSE +21 -0
  2. qualitranslate-1.5.1/PKG-INFO +84 -0
  3. qualitranslate-1.5.1/README.md +63 -0
  4. qualitranslate-1.5.1/pyproject.toml +37 -0
  5. qualitranslate-1.5.1/setup.cfg +4 -0
  6. qualitranslate-1.5.1/src/qualitranslate/__init__.py +11 -0
  7. qualitranslate-1.5.1/src/qualitranslate/__main__.py +3 -0
  8. qualitranslate-1.5.1/src/qualitranslate/app.py +223 -0
  9. qualitranslate-1.5.1/src/qualitranslate/core.py +1172 -0
  10. qualitranslate-1.5.1/src/qualitranslate/document_tools.py +109 -0
  11. qualitranslate-1.5.1/src/qualitranslate/privacy.py +75 -0
  12. qualitranslate-1.5.1/src/qualitranslate/prompts.json +22 -0
  13. qualitranslate-1.5.1/src/qualitranslate/prompts.py +19 -0
  14. qualitranslate-1.5.1/src/qualitranslate/quality.py +21 -0
  15. qualitranslate-1.5.1/src/qualitranslate/web/app.js +562 -0
  16. qualitranslate-1.5.1/src/qualitranslate/web/index.html +66 -0
  17. qualitranslate-1.5.1/src/qualitranslate/web/style.css +44 -0
  18. qualitranslate-1.5.1/src/qualitranslate.egg-info/PKG-INFO +84 -0
  19. qualitranslate-1.5.1/src/qualitranslate.egg-info/SOURCES.txt +24 -0
  20. qualitranslate-1.5.1/src/qualitranslate.egg-info/dependency_links.txt +1 -0
  21. qualitranslate-1.5.1/src/qualitranslate.egg-info/entry_points.txt +2 -0
  22. qualitranslate-1.5.1/src/qualitranslate.egg-info/top_level.txt +1 -0
  23. qualitranslate-1.5.1/tests/test_model_settings.py +211 -0
  24. qualitranslate-1.5.1/tests/test_packaging.py +31 -0
  25. qualitranslate-1.5.1/tests/test_research_workflow.py +223 -0
  26. qualitranslate-1.5.1/tests/test_workflow.py +363 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali Yalcinkaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: qualitranslate
3
+ Version: 1.5.1
4
+ Summary: Forward translation, blind back-translation, reconciliation and recorded human review for qualitative interview transcripts
5
+ Author-email: Ali Yalcinkaya <aliyalcink@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AliYalcinkayaMD/qualitranslate
8
+ Project-URL: Source, https://github.com/AliYalcinkayaMD/qualitranslate
9
+ Project-URL: Issues, https://github.com/AliYalcinkayaMD/qualitranslate/issues
10
+ Keywords: qualitative research,translation,back-translation,interviews,cross-language research
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Classifier: Topic :: Text Processing :: Linguistic
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # QualiTranslate (Python)
23
+
24
+ QualiTranslate helps research teams translate qualitative interview transcripts with large language models while keeping the steps that make translation in cross-language research defensible:
25
+
26
+ **forward translation → blind back-translation → reconciliation → recorded human review**
27
+
28
+ It runs on your own computer as a small local web page. Nothing is saved to disk unless you download it, and nothing is sent anywhere until you choose a translation tool.
29
+
30
+ The same tool is available for R as the `qualitranslate` R package. Both versions share the prompts, the interface and the project file format, so a project started in one can be continued in the other.
31
+
32
+ ## Install and start
33
+
34
+ You need Python 3.9 or newer. On a Mac, use the installer from [python.org](https://www.python.org/downloads/macos/) if Python is not installed yet.
35
+
36
+ ```bash
37
+ python3 -m pip install qualitranslate
38
+ qualitranslate
39
+ ```
40
+
41
+ Your browser opens the workspace at `http://127.0.0.1:8765` (or the next free address shown in the Terminal). Keep the Terminal window open while you work. Press Control+C there to stop.
42
+
43
+ If the `qualitranslate` command is not found, start it with `python3 -m qualitranslate`.
44
+
45
+ QualiTranslate uses only the Python standard library.
46
+
47
+ ## What it does
48
+
49
+ 1. **Add a transcript.** Paste text or open a `.docx`, `.txt` or `.md` file. The app splits it into sections by speaker turn or paragraph and points out headers, notes and tables, so you can confirm what enters translation. A quick local check warns about possible identifiers (e-mail addresses, phone-like and CPR-like numbers, web addresses, full dates). It never changes your text, and it cannot find names or places.
50
+ 2. **Choose a tool.** Each stage can use a different tool:
51
+ - **Claude API** or **Mistral API**: automatic, with your own API key.
52
+ - **Any AI chat** (for example ChatGPT, Claude, Microsoft Copilot or Mistral's Le Chat), by copying a prepared prompt packet and pasting the reply back.
53
+ 3. **Translate.** The app runs forward translation, blind back-translation and reconciliation as separate requests. The back-translation request contains only the forward translation: never the original, the study context or the glossary.
54
+ 4. **Review.** Reconciliation flags possible problems by category (for example negation, omission, temporality, idioms and metaphors) and severity. A named reviewer records a decision for each section.
55
+ 5. **Download.** One ZIP holds the translation table (CSV), a side-by-side report (HTML), the project file, every prompt and reply, the audit trail and a methods record that starts with a **draft methods paragraph** for your manuscript.
56
+
57
+ Try **Try the demo** first. It runs all three stages on fictional data without calling any AI service.
58
+
59
+ ## Model settings
60
+
61
+ Under **Advanced settings** each stage has its own provider and model, plus:
62
+
63
+ - **Temperature or top-p**, sent only when you set them. Some current Claude models accept only their default temperature; if a request is rejected, set it back to *Provider default*.
64
+ - **Reasoning effort**: for Claude models that support it (`low` to `max`) and Mistral models with adjustable reasoning (`none` or `high`). It mostly matters for reconciliation, where finding subtle discrepancies benefits from more reasoning.
65
+ - **Random seed** (Mistral only). A seed does not guarantee identical translations.
66
+ - A **different model for blind back-translation** can catch errors that the first model would repeat. In the guided setup choose it under *Blind back-translation model*.
67
+
68
+ In copy-and-paste mode these settings are not sent anywhere; set them in the chat itself if it offers them, and record what you used.
69
+
70
+ Changing a later stage's settings keeps the earlier stages: for example, a new back-translation model clears only the back-translations and reconciliations.
71
+
72
+ ## Data protection
73
+
74
+ - Text is sent only to the service you choose, in batches. Use a tool and account that your institution has approved for the data in your study, and pseudonymise transcripts first when your approval requires it.
75
+ - API keys are kept only in memory while the app runs. They are never written to disk or included in exports.
76
+ - Projects stay in memory until you download them.
77
+
78
+ ## What it does not claim
79
+
80
+ Back-translation is a diagnostic aid. Separate requests to the same model are not independent human translators, and a new or temporary chat does not prove that a service has no other context. The methods record states what was actually done, including the tools, models and settings used, and does not claim validation or equivalence.
81
+
82
+ ## Licence
83
+
84
+ MIT. See `LICENSE`.
@@ -0,0 +1,63 @@
1
+ # QualiTranslate (Python)
2
+
3
+ QualiTranslate helps research teams translate qualitative interview transcripts with large language models while keeping the steps that make translation in cross-language research defensible:
4
+
5
+ **forward translation → blind back-translation → reconciliation → recorded human review**
6
+
7
+ It runs on your own computer as a small local web page. Nothing is saved to disk unless you download it, and nothing is sent anywhere until you choose a translation tool.
8
+
9
+ The same tool is available for R as the `qualitranslate` R package. Both versions share the prompts, the interface and the project file format, so a project started in one can be continued in the other.
10
+
11
+ ## Install and start
12
+
13
+ You need Python 3.9 or newer. On a Mac, use the installer from [python.org](https://www.python.org/downloads/macos/) if Python is not installed yet.
14
+
15
+ ```bash
16
+ python3 -m pip install qualitranslate
17
+ qualitranslate
18
+ ```
19
+
20
+ Your browser opens the workspace at `http://127.0.0.1:8765` (or the next free address shown in the Terminal). Keep the Terminal window open while you work. Press Control+C there to stop.
21
+
22
+ If the `qualitranslate` command is not found, start it with `python3 -m qualitranslate`.
23
+
24
+ QualiTranslate uses only the Python standard library.
25
+
26
+ ## What it does
27
+
28
+ 1. **Add a transcript.** Paste text or open a `.docx`, `.txt` or `.md` file. The app splits it into sections by speaker turn or paragraph and points out headers, notes and tables, so you can confirm what enters translation. A quick local check warns about possible identifiers (e-mail addresses, phone-like and CPR-like numbers, web addresses, full dates). It never changes your text, and it cannot find names or places.
29
+ 2. **Choose a tool.** Each stage can use a different tool:
30
+ - **Claude API** or **Mistral API**: automatic, with your own API key.
31
+ - **Any AI chat** (for example ChatGPT, Claude, Microsoft Copilot or Mistral's Le Chat), by copying a prepared prompt packet and pasting the reply back.
32
+ 3. **Translate.** The app runs forward translation, blind back-translation and reconciliation as separate requests. The back-translation request contains only the forward translation: never the original, the study context or the glossary.
33
+ 4. **Review.** Reconciliation flags possible problems by category (for example negation, omission, temporality, idioms and metaphors) and severity. A named reviewer records a decision for each section.
34
+ 5. **Download.** One ZIP holds the translation table (CSV), a side-by-side report (HTML), the project file, every prompt and reply, the audit trail and a methods record that starts with a **draft methods paragraph** for your manuscript.
35
+
36
+ Try **Try the demo** first. It runs all three stages on fictional data without calling any AI service.
37
+
38
+ ## Model settings
39
+
40
+ Under **Advanced settings** each stage has its own provider and model, plus:
41
+
42
+ - **Temperature or top-p**, sent only when you set them. Some current Claude models accept only their default temperature; if a request is rejected, set it back to *Provider default*.
43
+ - **Reasoning effort**: for Claude models that support it (`low` to `max`) and Mistral models with adjustable reasoning (`none` or `high`). It mostly matters for reconciliation, where finding subtle discrepancies benefits from more reasoning.
44
+ - **Random seed** (Mistral only). A seed does not guarantee identical translations.
45
+ - A **different model for blind back-translation** can catch errors that the first model would repeat. In the guided setup choose it under *Blind back-translation model*.
46
+
47
+ In copy-and-paste mode these settings are not sent anywhere; set them in the chat itself if it offers them, and record what you used.
48
+
49
+ Changing a later stage's settings keeps the earlier stages: for example, a new back-translation model clears only the back-translations and reconciliations.
50
+
51
+ ## Data protection
52
+
53
+ - Text is sent only to the service you choose, in batches. Use a tool and account that your institution has approved for the data in your study, and pseudonymise transcripts first when your approval requires it.
54
+ - API keys are kept only in memory while the app runs. They are never written to disk or included in exports.
55
+ - Projects stay in memory until you download them.
56
+
57
+ ## What it does not claim
58
+
59
+ Back-translation is a diagnostic aid. Separate requests to the same model are not independent human translators, and a new or temporary chat does not prove that a service has no other context. The methods record states what was actually done, including the tools, models and settings used, and does not claim validation or equivalence.
60
+
61
+ ## Licence
62
+
63
+ MIT. See `LICENSE`.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qualitranslate"
7
+ version = "1.5.1"
8
+ description = "Forward translation, blind back-translation, reconciliation and recorded human review for qualitative interview transcripts"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Ali Yalcinkaya", email = "aliyalcink@gmail.com" }]
14
+ keywords = ["qualitative research", "translation", "back-translation", "interviews", "cross-language research"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering",
21
+ "Topic :: Text Processing :: Linguistic",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/AliYalcinkayaMD/qualitranslate"
27
+ Source = "https://github.com/AliYalcinkayaMD/qualitranslate"
28
+ Issues = "https://github.com/AliYalcinkayaMD/qualitranslate/issues"
29
+
30
+ [project.scripts]
31
+ qualitranslate = "qualitranslate.app:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ qualitranslate = ["prompts.json", "web/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """QualiTranslate: forward translation, blind back-translation, reconciliation and
2
+ human review of qualitative interview transcripts, in a local browser workspace."""
3
+ from .core import APP_VERSION as __version__
4
+
5
+ __all__ = ["__version__", "main"]
6
+
7
+
8
+ def main(argv=None):
9
+ """Start the local QualiTranslate workspace (same as the `qualitranslate` command)."""
10
+ from .app import main as _main
11
+ return _main(argv)
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ """Start the local QualiTranslate workspace: python3 app.py"""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import secrets
8
+ import threading
9
+ import webbrowser
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from pathlib import Path
12
+
13
+ from .core import (APP_VERSION, DEMO_SOURCE, WorkflowError, Workspace, export_bundle,
14
+ export_csv, export_html, methods_paragraph, read_document, read_upload)
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ MAX_REQUEST = 25_000_000
18
+
19
+
20
+ def make_handler(workspace, token):
21
+ class Handler(BaseHTTPRequestHandler):
22
+ server_version = "QualiTranslate/" + APP_VERSION
23
+
24
+ def log_message(self, *args):
25
+ # The standard logger would print request URLs and project activity.
26
+ pass
27
+
28
+ def _host_ok(self):
29
+ return self.headers.get("Host") in {"127.0.0.1:" + str(self.server.server_port), "localhost:" + str(self.server.server_port)}
30
+
31
+ def _authorized(self):
32
+ origin = self.headers.get("Origin")
33
+ allowed = {"http://127.0.0.1:" + str(self.server.server_port), "http://localhost:" + str(self.server.server_port)}
34
+ supplied = self.headers.get("X-QualiTranslate-Token", "")
35
+ # Compare as bytes: compare_digest refuses non-ASCII str, which would drop the connection.
36
+ return self._host_ok() and (origin is None or origin in allowed) and secrets.compare_digest(supplied.encode("utf-8", "surrogateescape"), token.encode("utf-8"))
37
+
38
+ def _send(self, data, content_type="application/json; charset=utf-8", status=200, filename=None):
39
+ if not isinstance(data, bytes):
40
+ data = json.dumps(data, ensure_ascii=False).encode()
41
+ self.send_response(status)
42
+ self.send_header("Content-Type", content_type)
43
+ self.send_header("Content-Length", str(len(data)))
44
+ self.send_header("Cache-Control", "no-store")
45
+ self.send_header("X-Content-Type-Options", "nosniff")
46
+ self.send_header("Referrer-Policy", "no-referrer")
47
+ self.send_header("X-Frame-Options", "DENY")
48
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
49
+ if filename:
50
+ self.send_header("Content-Disposition", 'attachment; filename="' + filename + '"')
51
+ self.end_headers()
52
+ self.wfile.write(data)
53
+
54
+ def do_GET(self):
55
+ if not self._host_ok():
56
+ self._send({"error": "Host rejected."}, status=403)
57
+ return
58
+ if self.path in {"/", "/index.html"}:
59
+ data = (ROOT / "web" / "index.html").read_text(encoding="utf-8").replace("__CSRF_TOKEN__", token)
60
+ self._send(data.encode(), "text/html; charset=utf-8")
61
+ elif self.path in {"/assets/app.js", "/assets/style.css"}:
62
+ filename = self.path.rsplit("/", 1)[1]
63
+ mime = "text/javascript; charset=utf-8" if filename.endswith(".js") else "text/css; charset=utf-8"
64
+ self._send((ROOT / "web" / filename).read_bytes(), mime)
65
+ elif self.path == "/api/state" and self._authorized():
66
+ state = workspace.snapshot()
67
+ # Exact prompt history is exported separately, not resent on each UI poll.
68
+ runs = state["project"].pop("runs")
69
+ state["project"]["run_count"] = len(runs)
70
+ state["project"]["last_manual_label"] = next((r.get("provenance", {}).get("tool_model_reported_by_user", "") for r in reversed(runs) if r.get("provenance", {}).get("provider") == "manual"), "")
71
+ state["project"]["audit_count"] = len(state["project"].pop("audit"))
72
+ self._send(state)
73
+ elif self.path == "/health":
74
+ self._send({"app": "QualiTranslate", "version": APP_VERSION})
75
+ else:
76
+ self._send({"error": "Not found or access denied."}, status=404)
77
+
78
+ def do_POST(self):
79
+ if not self._authorized():
80
+ self._send({"error": "Request rejected. Open the app from its local address."}, status=403)
81
+ return
82
+ try:
83
+ length = int(self.headers.get("Content-Length", "0"))
84
+ if not 0 < length <= MAX_REQUEST:
85
+ self._send({"error": "Request is empty or exceeds 25 MB."}, status=413)
86
+ return
87
+ if self.headers.get_content_type() != "application/json":
88
+ raise WorkflowError("Requests must use application/json.")
89
+ body = json.loads(self.rfile.read(length))
90
+ if not isinstance(body, dict):
91
+ raise WorkflowError("Request must be a JSON object.")
92
+ route = self.path
93
+ if route == "/api/import":
94
+ workspace.load_text(body.get("text", ""), body.get("title", "Untitled interview"), split_speakers=body.get("split_speakers", True) is True)
95
+ elif route == "/api/file":
96
+ self._send(read_document(body.get("filename", ""), body.get("data", "")))
97
+ return
98
+ elif route == "/api/inspect":
99
+ self._send(workspace.inspect_document(body.get("text", ""), body.get("blocks")))
100
+ return
101
+ elif route == "/api/import-selected":
102
+ workspace.import_selected(body.get("inspection_id"), body.get("included_ids"), body.get("title", "Untitled interview"), body.get("settings"), body.get("split_speakers", True) is True)
103
+ elif route == "/api/demo":
104
+ workspace.load_text(DEMO_SOURCE, "Synthetic sample · treatment success", demo=True)
105
+ elif route == "/api/settings":
106
+ workspace.save_settings(body.get("settings"))
107
+ elif route == "/api/key":
108
+ workspace.set_key(body.get("provider"), body.get("key", ""))
109
+ elif route == "/api/models":
110
+ provider = body.get("provider")
111
+ with workspace.lock:
112
+ key = workspace.keys.get(provider, "")
113
+ self._send({"models": workspace.client.models(provider, key, body.get("workspace_id", ""))})
114
+ return
115
+ elif route == "/api/prompt":
116
+ task = workspace.prepare_task(body.get("stage"))
117
+ task["copy_text"] = task["system"] + "\n\nINPUT JSON:\n" + task["user"]
118
+ self._send(task)
119
+ return
120
+ elif route == "/api/manual":
121
+ workspace.apply_manual(body.get("task_id"), body.get("response", ""), body.get("model_label", ""))
122
+ elif route == "/api/run":
123
+ workspace.start_job(body.get("stage"), demo=body.get("demo") is True,
124
+ expected_project_id=body.get('expected_project_id'),expected_revision=body.get('expected_revision'))
125
+ elif route == "/api/methods":
126
+ self._send({"paragraph": methods_paragraph(workspace.snapshot()["project"])})
127
+ return
128
+ elif route == "/api/stop":
129
+ workspace.cancel.set()
130
+ elif route == "/api/edit":
131
+ workspace.edit(body.get("id"), body.get("stage"), body.get("text", ""), body.get("note", ""))
132
+ elif route == "/api/review":
133
+ workspace.review(body.get("id"), body.get("text", ""), body.get("reviewer", ""), body.get("note", ""), body.get("uncertainty") is True, body.get("method", "individual"))
134
+ elif route == "/api/review-batch":
135
+ workspace.review_batch_minor(body.get("ids"), body.get("reviewer", ""), body.get("revision"))
136
+ elif route == "/api/review-clear":
137
+ workspace.clear_review(body.get("id"))
138
+ elif route == "/api/project":
139
+ workspace.import_project(body.get("project"))
140
+ elif route == "/api/reset":
141
+ workspace.clear()
142
+ elif route == "/api/export":
143
+ project = workspace.snapshot()["project"]
144
+ kind = body.get("kind", "zip")
145
+ if kind == "json":
146
+ data, mime, name = json.dumps(project, ensure_ascii=False, indent=2).encode(), "application/json", "qualitranslate-project.json"
147
+ elif kind == "csv":
148
+ data, mime, name = export_csv(project), "text/csv; charset=utf-8", "qualitranslate-translations.csv"
149
+ elif kind == "html":
150
+ data, mime, name = export_html(project), "text/html; charset=utf-8", "qualitranslate-parallel-review.html"
151
+ elif kind == "zip":
152
+ data, mime, name = export_bundle(project), "application/zip", "qualitranslate-research-bundle.zip"
153
+ else:
154
+ raise WorkflowError("Unknown export format.")
155
+ self._send(data, mime, filename=name)
156
+ return
157
+ else:
158
+ self._send({"error": "Unknown endpoint."}, status=404)
159
+ return
160
+ self._send({"ok": True})
161
+ except (WorkflowError, json.JSONDecodeError, TypeError, ValueError, AttributeError) as exc:
162
+ message = str(exc) if isinstance(exc, WorkflowError) else "Invalid request format."
163
+ self._send({"error": message}, status=400)
164
+ except Exception:
165
+ self._send({"error": "An internal error occurred. Completed work remains in memory; export your project before restarting."}, status=500)
166
+
167
+ def do_OPTIONS(self):
168
+ self._send({"error": "Cross-origin access is disabled."}, status=403)
169
+
170
+ return Handler
171
+
172
+
173
+ def running_version(port):
174
+ """Return the version of a QualiTranslate already serving this port, if any."""
175
+ try:
176
+ import urllib.request
177
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
178
+ with opener.open("http://127.0.0.1:" + str(port) + "/health", timeout=1.5) as response:
179
+ data = json.loads(response.read(2000))
180
+ return str(data.get("version", "unknown")) if data.get("app") == "QualiTranslate" else ""
181
+ except Exception:
182
+ return ""
183
+
184
+
185
+ def main(argv=None):
186
+ parser = argparse.ArgumentParser(description="Local qualitative translation workspace")
187
+ parser.add_argument("--port", type=int, default=8765)
188
+ parser.add_argument("--no-browser", action="store_true")
189
+ args = parser.parse_args(argv)
190
+ if not 0 <= args.port <= 65535:
191
+ parser.error("Port must be between 0 and 65535.")
192
+ workspace, token = Workspace(), secrets.token_urlsafe(32)
193
+ server = None
194
+ for port in [args.port] + ([p for p in range(args.port + 1, args.port + 11)] if args.port else []):
195
+ try:
196
+ server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(workspace, token))
197
+ break
198
+ except OSError:
199
+ other = running_version(port)
200
+ if other:
201
+ print("Note: QualiTranslate " + other + " is already running at http://127.0.0.1:" + str(port) +
202
+ " (probably in another Terminal window). This new copy will use a different address." +
203
+ ("" if other == APP_VERSION else " Close the older window when you no longer need it, so you do not use the old version by mistake."))
204
+ if server is None:
205
+ print("No free local port was found. Close other QualiTranslate Terminal windows and try again.")
206
+ return 1
207
+ url = "http://127.0.0.1:" + str(server.server_port)
208
+ print("QualiTranslate " + APP_VERSION + "\nOpen " + url + "\nKeep this window open. Press Control+C to stop.\nProject data stays in memory until you explicitly export it. API mode sends selected text to the chosen provider.")
209
+ if not args.no_browser:
210
+ threading.Timer(0.7, lambda: webbrowser.open(url)).start()
211
+ try:
212
+ server.serve_forever()
213
+ except KeyboardInterrupt:
214
+ print("\nStopping. In-memory project and credentials will be cleared.")
215
+ finally:
216
+ workspace.cancel.set()
217
+ workspace.keys.clear()
218
+ server.server_close()
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ raise SystemExit(main())