Spaces:
Sleeping
Sleeping
P0-R7-D-R2 research-material-only reliability closure
#148
by GuXiaopeng - opened
- .streamlit/config.toml +1 -1
- app.py +252 -169
- config.py +15 -9
- core/advanced_method_registry.py +9 -0
- core/qualitative_engine.py +26 -8
- i18n.py +48 -16
- utils/file_parser.py +148 -0
.streamlit/config.toml
CHANGED
|
@@ -3,7 +3,7 @@ enableStaticServing = true
|
|
| 3 |
enableCORS = false
|
| 4 |
enableXsrfProtection = false
|
| 5 |
enableWebsocketCompression = false
|
| 6 |
-
maxUploadSize =
|
| 7 |
|
| 8 |
[browser]
|
| 9 |
gatherUsageStats = false
|
|
|
|
| 3 |
enableCORS = false
|
| 4 |
enableXsrfProtection = false
|
| 5 |
enableWebsocketCompression = false
|
| 6 |
+
maxUploadSize = 10
|
| 7 |
|
| 8 |
[browser]
|
| 9 |
gatherUsageStats = false
|
app.py
CHANGED
|
@@ -39,7 +39,11 @@ from core.advanced_method_registry import (
|
|
| 39 |
reconcile_method_authority, resolve_generation_preflight,
|
| 40 |
)
|
| 41 |
from smartpaper_restore_bridge import edge_bridge
|
| 42 |
-
from utils.file_parser import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
st.set_page_config(
|
| 45 |
page_title="Article Agent",
|
|
@@ -507,14 +511,18 @@ def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
|
|
| 507 |
|
| 508 |
def _research_material_upload_policy() -> dict:
|
| 509 |
policy = dict(APP_CONFIG.get("research_material_upload_policy") or {})
|
| 510 |
-
reference_limit = int(policy.get("reference_file_limit") or 0)
|
| 511 |
research_limit = int(policy.get("research_material_file_limit") or 0)
|
| 512 |
-
if
|
| 513 |
raise RuntimeError("research_material_upload_policy_invalid")
|
| 514 |
return {
|
| 515 |
**policy,
|
| 516 |
-
"reference_file_limit": reference_limit,
|
| 517 |
"research_material_file_limit": research_limit,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
"qualitative_min_combined_chars": max(
|
| 519 |
1, int(policy.get("qualitative_min_combined_chars") or 1)
|
| 520 |
),
|
|
@@ -537,6 +545,7 @@ def _uploaded_file_identity(item, occurrence: int = 0) -> str:
|
|
| 537 |
|
| 538 |
|
| 539 |
def _uploaded_material_entries() -> list[dict]:
|
|
|
|
| 540 |
uploads = list(st.session_state.get("up_research_materials") or [])
|
| 541 |
occurrences: dict[tuple[str, str, str], int] = {}
|
| 542 |
entries: list[dict] = []
|
|
@@ -547,23 +556,14 @@ def _uploaded_material_entries() -> list[dict]:
|
|
| 547 |
signature = (name, size, mime)
|
| 548 |
occurrence = occurrences.get(signature, 0)
|
| 549 |
occurrences[signature] = occurrence + 1
|
| 550 |
-
identity = _uploaded_file_identity(item, occurrence)
|
| 551 |
-
key = f"sp_material_role_{identity}"
|
| 552 |
-
ext = Path(name).suffix.lower()
|
| 553 |
-
default_role = "research" if ext in {".txt", ".csv", ".xls", ".xlsx"} else "reference"
|
| 554 |
-
if key not in st.session_state:
|
| 555 |
-
st.session_state[key] = default_role
|
| 556 |
-
role = str(st.session_state.get(key) or default_role).strip().lower()
|
| 557 |
-
if role not in {"reference", "research"}:
|
| 558 |
-
role = default_role
|
| 559 |
entries.append(
|
| 560 |
{
|
| 561 |
"item": item,
|
| 562 |
-
"identity":
|
| 563 |
-
"state_key": key,
|
| 564 |
"name": name,
|
| 565 |
-
"extension":
|
| 566 |
-
"
|
|
|
|
| 567 |
}
|
| 568 |
)
|
| 569 |
return entries
|
|
@@ -571,81 +571,165 @@ def _uploaded_material_entries() -> list[dict]:
|
|
| 571 |
|
| 572 |
def _material_role_counts(entries: list[dict] | None = None) -> dict[str, int]:
|
| 573 |
entries = list(entries if entries is not None else _uploaded_material_entries())
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
}
|
| 578 |
|
| 579 |
|
| 580 |
def _material_limits_ok(entries: list[dict] | None = None) -> tuple[bool, dict[str, int], dict]:
|
| 581 |
entries = list(entries if entries is not None else _uploaded_material_entries())
|
| 582 |
counts = _material_role_counts(entries)
|
| 583 |
policy = _research_material_upload_policy()
|
| 584 |
-
ok =
|
| 585 |
-
counts["reference"] <= policy["reference_file_limit"]
|
| 586 |
-
and counts["research"] <= policy["research_material_file_limit"]
|
| 587 |
-
)
|
| 588 |
return ok, counts, policy
|
| 589 |
|
| 590 |
|
| 591 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
capability = method_capability(method_family)
|
| 593 |
material_routing = dict(capability.get("material_routing") or {})
|
| 594 |
-
|
| 595 |
-
bucket
|
| 596 |
-
if bucket not in {"reference", "data", "interview"}:
|
| 597 |
raise RuntimeError("method_material_routing_unsupported")
|
| 598 |
return bucket
|
| 599 |
|
| 600 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
def _material_preflight_report(method_family: str, entries: list[dict] | None = None) -> dict:
|
| 602 |
-
"""Validate
|
| 603 |
entries = list(entries if entries is not None else _uploaded_material_entries())
|
| 604 |
limits_ok, counts, policy = _material_limits_ok(entries)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
report = {
|
| 606 |
"ok": bool(limits_ok),
|
| 607 |
"counts": counts,
|
| 608 |
"policy": policy,
|
| 609 |
"failures": [],
|
| 610 |
"research_chars": 0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
}
|
| 612 |
if not limits_ok:
|
| 613 |
return report
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
cache = st.session_state.get("sp_material_parse_cache")
|
| 616 |
if not isinstance(cache, dict):
|
| 617 |
cache = {}
|
| 618 |
st.session_state["sp_material_parse_cache"] = cache
|
| 619 |
|
|
|
|
| 620 |
live_cache_keys = set()
|
|
|
|
| 621 |
for entry in entries:
|
| 622 |
item = entry["item"]
|
| 623 |
-
|
| 624 |
-
cache_key = f"{entry['identity']}|{
|
| 625 |
live_cache_keys.add(cache_key)
|
| 626 |
-
|
| 627 |
-
if
|
| 628 |
-
result =
|
| 629 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
if not bool(result.get("ok")):
|
| 631 |
report["failures"].append(
|
| 632 |
{
|
| 633 |
"name": entry["name"],
|
| 634 |
-
"role":
|
| 635 |
"error": str(result.get("error") or "parse_failed"),
|
| 636 |
}
|
| 637 |
)
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
|
| 641 |
-
# Drop stale cache entries when files are removed or roles change.
|
| 642 |
st.session_state["sp_material_parse_cache"] = {
|
| 643 |
key: value for key, value in cache.items() if key in live_cache_keys
|
| 644 |
}
|
| 645 |
|
| 646 |
if report["failures"]:
|
| 647 |
report["ok"] = False
|
| 648 |
-
|
|
|
|
|
|
|
| 649 |
if (
|
| 650 |
report["ok"]
|
| 651 |
and counts["research"] > 0
|
|
@@ -656,10 +740,8 @@ def _material_preflight_report(method_family: str, entries: list[dict] | None =
|
|
| 656 |
report["content_insufficient"] = True
|
| 657 |
return report
|
| 658 |
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
research_entries = [entry for entry in entries if entry.get("role") == "research"]
|
| 662 |
-
if not research_entries:
|
| 663 |
return ""
|
| 664 |
policy = _research_material_upload_policy()
|
| 665 |
if research_bucket == "data":
|
|
@@ -668,12 +750,19 @@ def _material_bundle_text(entries: list[dict], research_bucket: str) -> str:
|
|
| 668 |
total_budget = int(policy["qualitative_bundle_max_chars"])
|
| 669 |
else:
|
| 670 |
raise RuntimeError("method_material_routing_unsupported")
|
| 671 |
-
per_file_budget = max(1, total_budget // len(research_entries))
|
| 672 |
sections = []
|
| 673 |
-
for entry in
|
| 674 |
-
text =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
sections.append(f"### Source: {entry['name']}\n{text.strip()}")
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
def _uploaded_material_descriptors() -> list[dict[str, str]]:
|
| 679 |
out: list[dict[str, str]] = []
|
|
@@ -697,7 +786,7 @@ def _method_intent_signature() -> str:
|
|
| 697 |
"topic": _normalized_research_intent_text(
|
| 698 |
st.session_state.get("sp_topic") or DEFAULT_TOPIC
|
| 699 |
),
|
| 700 |
-
"keywords":
|
| 701 |
st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS
|
| 702 |
),
|
| 703 |
}
|
|
@@ -770,7 +859,7 @@ def _current_method_preflight(*, confirmed_method_family: str | None = None) ->
|
|
| 770 |
)
|
| 771 |
preflight = resolve_generation_preflight(
|
| 772 |
str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
|
| 773 |
-
|
| 774 |
materials=_uploaded_material_descriptors(),
|
| 775 |
confirmed_method_family=confirmed,
|
| 776 |
)
|
|
@@ -779,11 +868,11 @@ def _current_method_preflight(*, confirmed_method_family: str | None = None) ->
|
|
| 779 |
def _classify_and_save_research_materials(
|
| 780 |
method_family: str, inputs_dir: Path
|
| 781 |
) -> tuple[list[str], list[str], list[str], str, list[dict]]:
|
| 782 |
-
"""Persist
|
| 783 |
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
"""
|
| 788 |
entries = _uploaded_material_entries()
|
| 789 |
if not entries:
|
|
@@ -793,55 +882,36 @@ def _classify_and_save_research_materials(
|
|
| 793 |
if not report.get("ok"):
|
| 794 |
raise RuntimeError("research_material_preflight_failed")
|
| 795 |
|
| 796 |
-
reference_items = [entry["item"] for entry in entries if entry.get("role") == "reference"]
|
| 797 |
-
research_entries = [entry for entry in entries if entry.get("role") == "research"]
|
| 798 |
-
|
| 799 |
-
refs = _save_uploaded(reference_items, inputs_dir, "ref")
|
| 800 |
original_research = _save_uploaded(
|
| 801 |
-
[entry["item"] for entry in
|
| 802 |
)
|
| 803 |
|
| 804 |
data: list[str] = []
|
| 805 |
interviews: list[str] = []
|
| 806 |
capability = method_capability(method_family)
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
if
|
| 811 |
-
raise RuntimeError("
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
if research_bucket == "data":
|
| 821 |
-
data = [bundle_rel]
|
| 822 |
-
else:
|
| 823 |
-
interviews = [bundle_rel]
|
| 824 |
-
|
| 825 |
-
role_manifest = []
|
| 826 |
-
research_index = 0
|
| 827 |
-
reference_index = 0
|
| 828 |
-
for entry in entries:
|
| 829 |
-
role = str(entry.get("role") or "")
|
| 830 |
-
if role == "reference":
|
| 831 |
-
saved_path = refs[reference_index]
|
| 832 |
-
reference_index += 1
|
| 833 |
-
else:
|
| 834 |
-
saved_path = original_research[research_index]
|
| 835 |
-
research_index += 1
|
| 836 |
-
role_manifest.append(
|
| 837 |
-
{
|
| 838 |
-
"name": entry["name"],
|
| 839 |
-
"role": role,
|
| 840 |
-
"saved_path": saved_path,
|
| 841 |
-
}
|
| 842 |
-
)
|
| 843 |
|
| 844 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
|
| 846 |
def _persist_task_request(
|
| 847 |
task: dict,
|
|
@@ -881,7 +951,7 @@ def _persist_task_request(
|
|
| 881 |
"target_lang": get_target_language(lang_code),
|
| 882 |
"output_suffix": get_output_suffix(lang_code),
|
| 883 |
"topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
|
| 884 |
-
"keywords":
|
| 885 |
"target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
|
| 886 |
# High-entropy, display-only token. It is part of the immutable task
|
| 887 |
# request/hash, but it grants no Worker/D1 authority.
|
|
@@ -1157,8 +1227,8 @@ def _process_pending_launch(lang_code: str) -> None:
|
|
| 1157 |
|
| 1158 |
inject_wechat_browser_notice()
|
| 1159 |
|
| 1160 |
-
DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning
|
| 1161 |
-
DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized
|
| 1162 |
|
| 1163 |
def _current_language_code() -> str:
|
| 1164 |
choice = st.session_state.get("interface_language_choice")
|
|
@@ -1778,52 +1848,9 @@ def render_workspace_controls(user):
|
|
| 1778 |
key="sp_keywords",
|
| 1779 |
)
|
| 1780 |
|
| 1781 |
-
#
|
| 1782 |
-
#
|
| 1783 |
-
|
| 1784 |
-
tr(lang_code, "research_materials_label"),
|
| 1785 |
-
type=["pdf", "txt", "doc", "docx", "csv", "xls", "xlsx"],
|
| 1786 |
-
accept_multiple_files=True,
|
| 1787 |
-
key="up_research_materials",
|
| 1788 |
-
disabled=active,
|
| 1789 |
-
help=tr(lang_code, "research_materials_help"),
|
| 1790 |
-
)
|
| 1791 |
-
|
| 1792 |
-
uploaded_materials = list(st.session_state.get("up_research_materials") or [])
|
| 1793 |
-
material_entries = _uploaded_material_entries()
|
| 1794 |
-
if uploaded_materials and not active:
|
| 1795 |
-
for entry in material_entries:
|
| 1796 |
-
st.radio(
|
| 1797 |
-
entry["name"],
|
| 1798 |
-
["reference", "research"],
|
| 1799 |
-
format_func=lambda value: tr(lang_code, f"material_use_{value}"),
|
| 1800 |
-
horizontal=True,
|
| 1801 |
-
key=entry["state_key"],
|
| 1802 |
-
)
|
| 1803 |
-
# Re-read after widgets because Streamlit may have changed role state.
|
| 1804 |
-
material_entries = _uploaded_material_entries()
|
| 1805 |
-
limits_ok, material_counts, material_policy = _material_limits_ok(material_entries)
|
| 1806 |
-
if material_counts["reference"] > material_policy["reference_file_limit"]:
|
| 1807 |
-
st.error(
|
| 1808 |
-
tr(
|
| 1809 |
-
lang_code,
|
| 1810 |
-
"material_reference_limit_exceeded",
|
| 1811 |
-
limit=material_policy["reference_file_limit"],
|
| 1812 |
-
)
|
| 1813 |
-
)
|
| 1814 |
-
if material_counts["research"] > material_policy["research_material_file_limit"]:
|
| 1815 |
-
st.error(
|
| 1816 |
-
tr(
|
| 1817 |
-
lang_code,
|
| 1818 |
-
"material_research_limit_exceeded",
|
| 1819 |
-
limit=material_policy["research_material_file_limit"],
|
| 1820 |
-
)
|
| 1821 |
-
)
|
| 1822 |
-
else:
|
| 1823 |
-
limits_ok = True
|
| 1824 |
-
material_counts = {"reference": 0, "research": 0}
|
| 1825 |
-
material_policy = _research_material_upload_policy()
|
| 1826 |
-
|
| 1827 |
if active:
|
| 1828 |
active_family = _task_method_family(task)
|
| 1829 |
active_capability = method_capability(active_family)
|
|
@@ -1862,29 +1889,6 @@ def render_workspace_controls(user):
|
|
| 1862 |
if active:
|
| 1863 |
st.markdown(f"**{tr(lang_code, 'detected_method_label')}:** {method_label}")
|
| 1864 |
|
| 1865 |
-
material_gate = {"ok": True, "failures": []}
|
| 1866 |
-
if not active and limits_ok:
|
| 1867 |
-
material_gate = _material_preflight_report(method_family, material_entries)
|
| 1868 |
-
for failure in material_gate.get("failures") or []:
|
| 1869 |
-
role = str(failure.get("role") or "reference")
|
| 1870 |
-
formats_key = (
|
| 1871 |
-
"material_reference_convert_formats"
|
| 1872 |
-
if role == "reference"
|
| 1873 |
-
else "material_research_convert_formats"
|
| 1874 |
-
)
|
| 1875 |
-
st.error(
|
| 1876 |
-
tr(
|
| 1877 |
-
lang_code,
|
| 1878 |
-
"material_parse_failed",
|
| 1879 |
-
filename=str(failure.get("name") or ""),
|
| 1880 |
-
formats=tr(lang_code, formats_key),
|
| 1881 |
-
)
|
| 1882 |
-
)
|
| 1883 |
-
if material_gate.get("content_insufficient"):
|
| 1884 |
-
st.error(tr(lang_code, "material_research_content_insufficient"))
|
| 1885 |
-
elif not active:
|
| 1886 |
-
material_gate = {"ok": False, "failures": []}
|
| 1887 |
-
|
| 1888 |
st.caption(tr(lang_code, "method_detection_explanation"))
|
| 1889 |
|
| 1890 |
block_reason = str(preflight.get("block_reason") or "")
|
|
@@ -1914,6 +1918,85 @@ def render_workspace_controls(user):
|
|
| 1914 |
else:
|
| 1915 |
st.markdown(tr(lang_code, "template_help_undetermined"))
|
| 1916 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1917 |
product_ui = get_product_ui_projection()
|
| 1918 |
product_ui_ready = product_ui.get("mode") == "a5_product"
|
| 1919 |
selected_remaining: int | None = None
|
|
|
|
| 39 |
reconcile_method_authority, resolve_generation_preflight,
|
| 40 |
)
|
| 41 |
from smartpaper_restore_bridge import edge_bridge
|
| 42 |
+
from utils.file_parser import (
|
| 43 |
+
extract_research_material_content,
|
| 44 |
+
research_material_text_fingerprint,
|
| 45 |
+
validate_research_material_template,
|
| 46 |
+
)
|
| 47 |
|
| 48 |
st.set_page_config(
|
| 49 |
page_title="Article Agent",
|
|
|
|
| 511 |
|
| 512 |
def _research_material_upload_policy() -> dict:
|
| 513 |
policy = dict(APP_CONFIG.get("research_material_upload_policy") or {})
|
|
|
|
| 514 |
research_limit = int(policy.get("research_material_file_limit") or 0)
|
| 515 |
+
if research_limit <= 0:
|
| 516 |
raise RuntimeError("research_material_upload_policy_invalid")
|
| 517 |
return {
|
| 518 |
**policy,
|
|
|
|
| 519 |
"research_material_file_limit": research_limit,
|
| 520 |
+
"research_material_file_max_bytes": max(
|
| 521 |
+
1024, int(policy.get("research_material_file_max_bytes") or 0)
|
| 522 |
+
),
|
| 523 |
+
"research_material_total_max_bytes": max(
|
| 524 |
+
1024, int(policy.get("research_material_total_max_bytes") or 0)
|
| 525 |
+
),
|
| 526 |
"qualitative_min_combined_chars": max(
|
| 527 |
1, int(policy.get("qualitative_min_combined_chars") or 1)
|
| 528 |
),
|
|
|
|
| 545 |
|
| 546 |
|
| 547 |
def _uploaded_material_entries() -> list[dict]:
|
| 548 |
+
"""Return research-material-only upload entries for the current design."""
|
| 549 |
uploads = list(st.session_state.get("up_research_materials") or [])
|
| 550 |
occurrences: dict[tuple[str, str, str], int] = {}
|
| 551 |
entries: list[dict] = []
|
|
|
|
| 556 |
signature = (name, size, mime)
|
| 557 |
occurrence = occurrences.get(signature, 0)
|
| 558 |
occurrences[signature] = occurrence + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
entries.append(
|
| 560 |
{
|
| 561 |
"item": item,
|
| 562 |
+
"identity": _uploaded_file_identity(item, occurrence),
|
|
|
|
| 563 |
"name": name,
|
| 564 |
+
"extension": Path(name).suffix.lower(),
|
| 565 |
+
"size_bytes": int(getattr(item, "size", 0) or 0),
|
| 566 |
+
"role": "research",
|
| 567 |
}
|
| 568 |
)
|
| 569 |
return entries
|
|
|
|
| 571 |
|
| 572 |
def _material_role_counts(entries: list[dict] | None = None) -> dict[str, int]:
|
| 573 |
entries = list(entries if entries is not None else _uploaded_material_entries())
|
| 574 |
+
# Keep the legacy reference key only for backward diagnostic compatibility.
|
| 575 |
+
# New-task UI exposes research materials only.
|
| 576 |
+
return {"reference": 0, "research": len(entries)}
|
|
|
|
| 577 |
|
| 578 |
|
| 579 |
def _material_limits_ok(entries: list[dict] | None = None) -> tuple[bool, dict[str, int], dict]:
|
| 580 |
entries = list(entries if entries is not None else _uploaded_material_entries())
|
| 581 |
counts = _material_role_counts(entries)
|
| 582 |
policy = _research_material_upload_policy()
|
| 583 |
+
ok = counts["research"] <= policy["research_material_file_limit"]
|
|
|
|
|
|
|
|
|
|
| 584 |
return ok, counts, policy
|
| 585 |
|
| 586 |
|
| 587 |
+
def _research_material_extension_contract(method_family: str) -> tuple[str, ...]:
|
| 588 |
+
capability = method_capability(method_family)
|
| 589 |
+
extensions = tuple(
|
| 590 |
+
str(item or "").strip().casefold().lstrip(".")
|
| 591 |
+
for item in (capability.get("research_material_extensions") or ())
|
| 592 |
+
if str(item or "").strip()
|
| 593 |
+
)
|
| 594 |
+
if len(extensions) != 1 or extensions[0] not in {"txt", "csv"}:
|
| 595 |
+
raise RuntimeError("method_research_material_extension_unsupported")
|
| 596 |
+
return extensions
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def _explicit_material_bucket(method_family: str, role: str = "research") -> str:
|
| 600 |
+
if str(role or "research").strip().casefold() != "research":
|
| 601 |
+
raise RuntimeError("reference_upload_not_supported")
|
| 602 |
capability = method_capability(method_family)
|
| 603 |
material_routing = dict(capability.get("material_routing") or {})
|
| 604 |
+
bucket = str(material_routing.get("user_research") or "").strip().casefold()
|
| 605 |
+
if bucket not in {"data", "interview"}:
|
|
|
|
| 606 |
raise RuntimeError("method_material_routing_unsupported")
|
| 607 |
return bucket
|
| 608 |
|
| 609 |
|
| 610 |
+
def _canonical_user_keywords(value: object) -> str:
|
| 611 |
+
"""Normalize all supported user separators while preserving phrase spaces."""
|
| 612 |
+
terms = []
|
| 613 |
+
seen = set()
|
| 614 |
+
for raw in re.split(r"[;;,,\n|]+", str(value or "")):
|
| 615 |
+
term = re.sub(r"\s+", " ", raw).strip()
|
| 616 |
+
key = term.casefold()
|
| 617 |
+
if not term or key in seen:
|
| 618 |
+
continue
|
| 619 |
+
terms.append(term)
|
| 620 |
+
seen.add(key)
|
| 621 |
+
return "; ".join(terms)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def _template_content_fingerprints(template_profile: str) -> set[str]:
|
| 625 |
+
out: set[str] = set()
|
| 626 |
+
for language_code in LANGUAGE_OPTIONS.values():
|
| 627 |
+
content = str(get_research_material_template(language_code, template_profile).get("content") or "")
|
| 628 |
+
if content.strip():
|
| 629 |
+
out.add(research_material_text_fingerprint(content))
|
| 630 |
+
return out
|
| 631 |
+
|
| 632 |
+
|
| 633 |
def _material_preflight_report(method_family: str, entries: list[dict] | None = None) -> dict:
|
| 634 |
+
"""Validate research files and capacity before task creation/quota reservation."""
|
| 635 |
entries = list(entries if entries is not None else _uploaded_material_entries())
|
| 636 |
limits_ok, counts, policy = _material_limits_ok(entries)
|
| 637 |
+
capability = method_capability(method_family)
|
| 638 |
+
template_profile = str(capability.get("template_profile") or "").strip()
|
| 639 |
+
expected_extensions = _research_material_extension_contract(method_family)
|
| 640 |
+
research_bucket = _explicit_material_bucket(method_family)
|
| 641 |
+
bundle_limit = int(
|
| 642 |
+
policy["quantitative_bundle_max_chars"]
|
| 643 |
+
if research_bucket == "data"
|
| 644 |
+
else policy["qualitative_bundle_max_chars"]
|
| 645 |
+
)
|
| 646 |
+
total_bytes = sum(max(0, int(entry.get("size_bytes") or 0)) for entry in entries)
|
| 647 |
report = {
|
| 648 |
"ok": bool(limits_ok),
|
| 649 |
"counts": counts,
|
| 650 |
"policy": policy,
|
| 651 |
"failures": [],
|
| 652 |
"research_chars": 0,
|
| 653 |
+
"bundle_chars": 0,
|
| 654 |
+
"total_bytes": total_bytes,
|
| 655 |
+
"template_profile": template_profile,
|
| 656 |
+
"expected_extensions": expected_extensions,
|
| 657 |
+
"bundle_limit_chars": bundle_limit,
|
| 658 |
}
|
| 659 |
if not limits_ok:
|
| 660 |
return report
|
| 661 |
+
if total_bytes > int(policy["research_material_total_max_bytes"]):
|
| 662 |
+
report["ok"] = False
|
| 663 |
+
report["total_size_exceeded"] = True
|
| 664 |
+
return report
|
| 665 |
|
| 666 |
cache = st.session_state.get("sp_material_parse_cache")
|
| 667 |
if not isinstance(cache, dict):
|
| 668 |
cache = {}
|
| 669 |
st.session_state["sp_material_parse_cache"] = cache
|
| 670 |
|
| 671 |
+
template_fingerprints = _template_content_fingerprints(template_profile)
|
| 672 |
live_cache_keys = set()
|
| 673 |
+
successful_entries = 0
|
| 674 |
for entry in entries:
|
| 675 |
item = entry["item"]
|
| 676 |
+
ext = str(entry.get("extension") or "").casefold().lstrip(".")
|
| 677 |
+
cache_key = f"{entry['identity']}|{template_profile}|r2"
|
| 678 |
live_cache_keys.add(cache_key)
|
| 679 |
+
size_bytes = max(0, int(entry.get("size_bytes") or 0))
|
| 680 |
+
if size_bytes > int(policy["research_material_file_max_bytes"]):
|
| 681 |
+
result = {
|
| 682 |
+
"ok": False,
|
| 683 |
+
"filename": entry["name"],
|
| 684 |
+
"text_chars": 0,
|
| 685 |
+
"byte_size": size_bytes,
|
| 686 |
+
"error": "research_material_file_too_large",
|
| 687 |
+
}
|
| 688 |
+
elif ext not in expected_extensions:
|
| 689 |
+
result = {
|
| 690 |
+
"ok": False,
|
| 691 |
+
"filename": entry["name"],
|
| 692 |
+
"text_chars": 0,
|
| 693 |
+
"byte_size": size_bytes,
|
| 694 |
+
"error": f"template_extension_mismatch:expected={expected_extensions[0]}",
|
| 695 |
+
}
|
| 696 |
+
else:
|
| 697 |
+
result = cache.get(cache_key)
|
| 698 |
+
if not isinstance(result, dict):
|
| 699 |
+
result = validate_research_material_template(
|
| 700 |
+
item,
|
| 701 |
+
template_profile,
|
| 702 |
+
max_chars=None,
|
| 703 |
+
max_bytes=int(policy["research_material_file_max_bytes"]),
|
| 704 |
+
disallowed_content_fingerprints=template_fingerprints,
|
| 705 |
+
)
|
| 706 |
+
cache[cache_key] = dict(result)
|
| 707 |
if not bool(result.get("ok")):
|
| 708 |
report["failures"].append(
|
| 709 |
{
|
| 710 |
"name": entry["name"],
|
| 711 |
+
"role": "research",
|
| 712 |
"error": str(result.get("error") or "parse_failed"),
|
| 713 |
}
|
| 714 |
)
|
| 715 |
+
else:
|
| 716 |
+
chars = int(result.get("text_chars") or 0)
|
| 717 |
+
report["research_chars"] += chars
|
| 718 |
+
report["bundle_chars"] += len(f"### Source: {entry['name']}\n") + chars
|
| 719 |
+
successful_entries += 1
|
| 720 |
+
|
| 721 |
+
if successful_entries > 1:
|
| 722 |
+
report["bundle_chars"] += 2 * (successful_entries - 1)
|
| 723 |
|
|
|
|
| 724 |
st.session_state["sp_material_parse_cache"] = {
|
| 725 |
key: value for key, value in cache.items() if key in live_cache_keys
|
| 726 |
}
|
| 727 |
|
| 728 |
if report["failures"]:
|
| 729 |
report["ok"] = False
|
| 730 |
+
if report["bundle_chars"] > bundle_limit:
|
| 731 |
+
report["ok"] = False
|
| 732 |
+
report["bundle_capacity_exceeded"] = True
|
| 733 |
if (
|
| 734 |
report["ok"]
|
| 735 |
and counts["research"] > 0
|
|
|
|
| 740 |
report["content_insufficient"] = True
|
| 741 |
return report
|
| 742 |
|
| 743 |
+
def _material_bundle_text(entries: list[dict], research_bucket: str, template_profile: str) -> str:
|
| 744 |
+
if not entries:
|
|
|
|
|
|
|
| 745 |
return ""
|
| 746 |
policy = _research_material_upload_policy()
|
| 747 |
if research_bucket == "data":
|
|
|
|
| 750 |
total_budget = int(policy["qualitative_bundle_max_chars"])
|
| 751 |
else:
|
| 752 |
raise RuntimeError("method_material_routing_unsupported")
|
|
|
|
| 753 |
sections = []
|
| 754 |
+
for entry in entries:
|
| 755 |
+
text = extract_research_material_content(
|
| 756 |
+
entry["item"],
|
| 757 |
+
template_profile,
|
| 758 |
+
max_chars=total_budget,
|
| 759 |
+
max_bytes=int(policy["research_material_file_max_bytes"]),
|
| 760 |
+
)
|
| 761 |
sections.append(f"### Source: {entry['name']}\n{text.strip()}")
|
| 762 |
+
bundle = "\n\n".join(sections).strip()
|
| 763 |
+
if len(bundle) > total_budget:
|
| 764 |
+
raise RuntimeError("research_material_bundle_capacity_exceeded")
|
| 765 |
+
return bundle
|
| 766 |
|
| 767 |
def _uploaded_material_descriptors() -> list[dict[str, str]]:
|
| 768 |
out: list[dict[str, str]] = []
|
|
|
|
| 786 |
"topic": _normalized_research_intent_text(
|
| 787 |
st.session_state.get("sp_topic") or DEFAULT_TOPIC
|
| 788 |
),
|
| 789 |
+
"keywords": _canonical_user_keywords(
|
| 790 |
st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS
|
| 791 |
),
|
| 792 |
}
|
|
|
|
| 859 |
)
|
| 860 |
preflight = resolve_generation_preflight(
|
| 861 |
str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
|
| 862 |
+
_canonical_user_keywords(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
|
| 863 |
materials=_uploaded_material_descriptors(),
|
| 864 |
confirmed_method_family=confirmed,
|
| 865 |
)
|
|
|
|
| 868 |
def _classify_and_save_research_materials(
|
| 869 |
method_family: str, inputs_dir: Path
|
| 870 |
) -> tuple[list[str], list[str], list[str], str, list[dict]]:
|
| 871 |
+
"""Persist template-compatible research materials for the selected design.
|
| 872 |
|
| 873 |
+
New tasks no longer accept uploaded reference literature. Research files are
|
| 874 |
+
combined into one deterministic text bundle so the frozen GenerationRunner
|
| 875 |
+
continues using its proven single empirical-input seam.
|
| 876 |
"""
|
| 877 |
entries = _uploaded_material_entries()
|
| 878 |
if not entries:
|
|
|
|
| 882 |
if not report.get("ok"):
|
| 883 |
raise RuntimeError("research_material_preflight_failed")
|
| 884 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
original_research = _save_uploaded(
|
| 886 |
+
[entry["item"] for entry in entries], inputs_dir, "research"
|
| 887 |
)
|
| 888 |
|
| 889 |
data: list[str] = []
|
| 890 |
interviews: list[str] = []
|
| 891 |
capability = method_capability(method_family)
|
| 892 |
+
research_bucket = _explicit_material_bucket(method_family)
|
| 893 |
+
template_profile = str(capability.get("template_profile") or "").strip()
|
| 894 |
+
bundle_text = _material_bundle_text(entries, research_bucket, template_profile)
|
| 895 |
+
if not bundle_text:
|
| 896 |
+
raise RuntimeError("research_material_bundle_empty")
|
| 897 |
+
bundle_name = "research_materials_bundle.txt"
|
| 898 |
+
bundle_path = inputs_dir / bundle_name
|
| 899 |
+
bundle_path.write_text(bundle_text, encoding="utf-8")
|
| 900 |
+
bundle_rel = str(Path("inputs") / bundle_name)
|
| 901 |
+
if research_bucket == "data":
|
| 902 |
+
data = [bundle_rel]
|
| 903 |
+
else:
|
| 904 |
+
interviews = [bundle_rel]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
|
| 906 |
+
role_manifest = [
|
| 907 |
+
{
|
| 908 |
+
"name": entry["name"],
|
| 909 |
+
"role": "research",
|
| 910 |
+
"saved_path": original_research[index],
|
| 911 |
+
}
|
| 912 |
+
for index, entry in enumerate(entries)
|
| 913 |
+
]
|
| 914 |
+
return [], data, interviews, "research_only_template_v2", role_manifest
|
| 915 |
|
| 916 |
def _persist_task_request(
|
| 917 |
task: dict,
|
|
|
|
| 951 |
"target_lang": get_target_language(lang_code),
|
| 952 |
"output_suffix": get_output_suffix(lang_code),
|
| 953 |
"topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
|
| 954 |
+
"keywords": _canonical_user_keywords(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
|
| 955 |
"target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
|
| 956 |
# High-entropy, display-only token. It is part of the immutable task
|
| 957 |
# request/hash, but it grants no Worker/D1 authority.
|
|
|
|
| 1227 |
|
| 1228 |
inject_wechat_browser_notice()
|
| 1229 |
|
| 1230 |
+
DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning"
|
| 1231 |
+
DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized education; Educational technologies; Recommendation system;"
|
| 1232 |
|
| 1233 |
def _current_language_code() -> str:
|
| 1234 |
choice = st.session_state.get("interface_language_choice")
|
|
|
|
| 1848 |
key="sp_keywords",
|
| 1849 |
)
|
| 1850 |
|
| 1851 |
+
# P0-R7-E1 simplified workspace: Research Design -> Recommended Template ->
|
| 1852 |
+
# Research Materials. New tasks accept empirical materials only; reference
|
| 1853 |
+
# literature upload is intentionally removed from the product surface.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1854 |
if active:
|
| 1855 |
active_family = _task_method_family(task)
|
| 1856 |
active_capability = method_capability(active_family)
|
|
|
|
| 1889 |
if active:
|
| 1890 |
st.markdown(f"**{tr(lang_code, 'detected_method_label')}:** {method_label}")
|
| 1891 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1892 |
st.caption(tr(lang_code, "method_detection_explanation"))
|
| 1893 |
|
| 1894 |
block_reason = str(preflight.get("block_reason") or "")
|
|
|
|
| 1918 |
else:
|
| 1919 |
st.markdown(tr(lang_code, "template_help_undetermined"))
|
| 1920 |
|
| 1921 |
+
allowed_extensions = (
|
| 1922 |
+
_research_material_extension_contract(method_family)
|
| 1923 |
+
if method_family in METHOD_FAMILIES
|
| 1924 |
+
else ()
|
| 1925 |
+
)
|
| 1926 |
+
uploader_help_key = (
|
| 1927 |
+
f"research_materials_help_{method_family}"
|
| 1928 |
+
if method_family in METHOD_FAMILIES
|
| 1929 |
+
else "research_materials_help_undetermined"
|
| 1930 |
+
)
|
| 1931 |
+
uploaded_widget = st.file_uploader(
|
| 1932 |
+
tr(lang_code, "research_materials_label"),
|
| 1933 |
+
type=list(allowed_extensions),
|
| 1934 |
+
accept_multiple_files=True,
|
| 1935 |
+
key=f"up_research_materials_{method_family}",
|
| 1936 |
+
disabled=active or not allowed_extensions,
|
| 1937 |
+
help=tr(lang_code, uploader_help_key),
|
| 1938 |
+
)
|
| 1939 |
+
# Canonical non-widget session projection used by request freeze/preflight.
|
| 1940 |
+
st.session_state["up_research_materials"] = list(uploaded_widget or [])
|
| 1941 |
+
|
| 1942 |
+
material_entries = _uploaded_material_entries()
|
| 1943 |
+
limits_ok, material_counts, material_policy = _material_limits_ok(material_entries)
|
| 1944 |
+
if material_counts["research"] > material_policy["research_material_file_limit"]:
|
| 1945 |
+
st.error(
|
| 1946 |
+
tr(
|
| 1947 |
+
lang_code,
|
| 1948 |
+
"material_research_limit_exceeded",
|
| 1949 |
+
limit=material_policy["research_material_file_limit"],
|
| 1950 |
+
)
|
| 1951 |
+
)
|
| 1952 |
+
|
| 1953 |
+
material_gate = {"ok": True, "failures": []}
|
| 1954 |
+
if not active and limits_ok:
|
| 1955 |
+
material_gate = _material_preflight_report(method_family, material_entries)
|
| 1956 |
+
expected_format = (material_gate.get("expected_extensions") or [""])[0].upper()
|
| 1957 |
+
for failure in material_gate.get("failures") or []:
|
| 1958 |
+
error_code = str(failure.get("error") or "")
|
| 1959 |
+
if error_code.startswith("research_material_file_too_large"):
|
| 1960 |
+
st.error(
|
| 1961 |
+
tr(
|
| 1962 |
+
lang_code,
|
| 1963 |
+
"material_file_size_exceeded",
|
| 1964 |
+
filename=str(failure.get("name") or ""),
|
| 1965 |
+
limit_mb=max(1, int(material_policy["research_material_file_max_bytes"]) // (1024 * 1024)),
|
| 1966 |
+
)
|
| 1967 |
+
)
|
| 1968 |
+
elif error_code == "template_example_not_replaced":
|
| 1969 |
+
st.error(
|
| 1970 |
+
tr(
|
| 1971 |
+
lang_code,
|
| 1972 |
+
"material_template_example_not_replaced",
|
| 1973 |
+
filename=str(failure.get("name") or ""),
|
| 1974 |
+
)
|
| 1975 |
+
)
|
| 1976 |
+
else:
|
| 1977 |
+
st.error(
|
| 1978 |
+
tr(
|
| 1979 |
+
lang_code,
|
| 1980 |
+
"material_template_parse_failed",
|
| 1981 |
+
filename=str(failure.get("name") or ""),
|
| 1982 |
+
format=expected_format,
|
| 1983 |
+
)
|
| 1984 |
+
)
|
| 1985 |
+
if material_gate.get("total_size_exceeded"):
|
| 1986 |
+
st.error(
|
| 1987 |
+
tr(
|
| 1988 |
+
lang_code,
|
| 1989 |
+
"material_total_size_exceeded",
|
| 1990 |
+
limit_mb=max(1, int(material_policy["research_material_total_max_bytes"]) // (1024 * 1024)),
|
| 1991 |
+
)
|
| 1992 |
+
)
|
| 1993 |
+
if material_gate.get("bundle_capacity_exceeded"):
|
| 1994 |
+
st.error(tr(lang_code, "material_bundle_capacity_exceeded"))
|
| 1995 |
+
if material_gate.get("content_insufficient"):
|
| 1996 |
+
st.error(tr(lang_code, "material_research_content_insufficient"))
|
| 1997 |
+
elif not active:
|
| 1998 |
+
material_gate = {"ok": False, "failures": []}
|
| 1999 |
+
|
| 2000 |
product_ui = get_product_ui_projection()
|
| 2001 |
product_ui_ready = product_ui.get("mode") == "a5_product"
|
| 2002 |
selected_remaining: int | None = None
|
config.py
CHANGED
|
@@ -68,21 +68,28 @@ def load_config():
|
|
| 68 |
"rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
|
| 69 |
"rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
|
| 70 |
"max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
|
| 71 |
-
# P0-R7-
|
| 72 |
-
#
|
|
|
|
| 73 |
"research_material_upload_policy": {
|
| 74 |
"version": os.environ.get(
|
| 75 |
"SMARTPAPER_RESEARCH_MATERIAL_UPLOAD_POLICY_VERSION",
|
| 76 |
-
"research-material-upload-policy-
|
| 77 |
-
).strip() or "research-material-upload-policy-
|
| 78 |
-
"reference_file_limit": max(
|
| 79 |
-
1,
|
| 80 |
-
min(100, int(os.environ.get("SMARTPAPER_REFERENCE_FILE_LIMIT", "10"))),
|
| 81 |
-
),
|
| 82 |
"research_material_file_limit": max(
|
| 83 |
1,
|
| 84 |
min(100, int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_FILE_LIMIT", "10"))),
|
| 85 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
# Preserve the existing qualitative engine's >100-character
|
| 87 |
# user-evidence threshold without duplicating it in UI code.
|
| 88 |
"qualitative_min_combined_chars": max(
|
|
@@ -97,7 +104,6 @@ def load_config():
|
|
| 97 |
1000,
|
| 98 |
int(os.environ.get("SMARTPAPER_QUALITATIVE_RESEARCH_BUNDLE_MAX_CHARS", os.environ.get("MAX_EXTRACT_CHARS", "150000"))),
|
| 99 |
),
|
| 100 |
-
"accepted_extensions": ["pdf", "txt", "doc", "docx", "csv", "xls", "xlsx"],
|
| 101 |
},
|
| 102 |
# Model requests may legitimately run longer than one Worker lease.
|
| 103 |
# The independent Executor renews authority continuously; model-token
|
|
|
|
| 68 |
"rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
|
| 69 |
"rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
|
| 70 |
"max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
|
| 71 |
+
# P0-R7-E1: research-material-only upload policy. File type is owned
|
| 72 |
+
# by the selected Research Design capability; app.py owns no format or
|
| 73 |
+
# count business literals.
|
| 74 |
"research_material_upload_policy": {
|
| 75 |
"version": os.environ.get(
|
| 76 |
"SMARTPAPER_RESEARCH_MATERIAL_UPLOAD_POLICY_VERSION",
|
| 77 |
+
"research-material-upload-policy-v2-template-strict",
|
| 78 |
+
).strip() or "research-material-upload-policy-v2-template-strict",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
"research_material_file_limit": max(
|
| 80 |
1,
|
| 81 |
min(100, int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_FILE_LIMIT", "10"))),
|
| 82 |
),
|
| 83 |
+
# P0-R7-E1-R2: application-level byte gates are policy-driven and
|
| 84 |
+
# evaluated before any strict parser reads user content.
|
| 85 |
+
"research_material_file_max_bytes": max(
|
| 86 |
+
1024,
|
| 87 |
+
int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_FILE_MAX_BYTES", str(2 * 1024 * 1024))),
|
| 88 |
+
),
|
| 89 |
+
"research_material_total_max_bytes": max(
|
| 90 |
+
1024,
|
| 91 |
+
int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_TOTAL_MAX_BYTES", str(10 * 1024 * 1024))),
|
| 92 |
+
),
|
| 93 |
# Preserve the existing qualitative engine's >100-character
|
| 94 |
# user-evidence threshold without duplicating it in UI code.
|
| 95 |
"qualitative_min_combined_chars": max(
|
|
|
|
| 104 |
1000,
|
| 105 |
int(os.environ.get("SMARTPAPER_QUALITATIVE_RESEARCH_BUNDLE_MAX_CHARS", os.environ.get("MAX_EXTRACT_CHARS", "150000"))),
|
| 106 |
),
|
|
|
|
| 107 |
},
|
| 108 |
# Model requests may legitimately run longer than one Worker lease.
|
| 109 |
# The independent Executor renews authority continuously; model-token
|
core/advanced_method_registry.py
CHANGED
|
@@ -34,6 +34,7 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
|
|
| 34 |
"engine_family": "qualitative_engine",
|
| 35 |
"template_profile": "qualitative_materials",
|
| 36 |
"material_profile": "qualitative_materials",
|
|
|
|
| 37 |
"material_routing": {
|
| 38 |
"structured": "data",
|
| 39 |
"text": "interview",
|
|
@@ -51,6 +52,7 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
|
|
| 51 |
"engine_family": "quantitative_engine",
|
| 52 |
"template_profile": "survey_quantitative_data",
|
| 53 |
"material_profile": "survey_quantitative_data",
|
|
|
|
| 54 |
"material_routing": {
|
| 55 |
"structured": "data",
|
| 56 |
"text": "reference",
|
|
@@ -342,6 +344,11 @@ def resolve_generation_preflight(
|
|
| 342 |
engine_family = str(capability.get("engine_family") or "").strip()
|
| 343 |
template_profile = str(capability.get("template_profile") or "").strip()
|
| 344 |
material_profile = str(capability.get("material_profile") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
material_routing = dict(capability.get("material_routing") or {})
|
| 346 |
routing_keys = {
|
| 347 |
"structured", "text", "document_reference", "document_research",
|
|
@@ -355,6 +362,8 @@ def resolve_generation_preflight(
|
|
| 355 |
or not engine_family
|
| 356 |
or not template_profile
|
| 357 |
or not material_profile
|
|
|
|
|
|
|
| 358 |
or set(material_routing) != routing_keys
|
| 359 |
or not routing_values.issubset({"reference", "data", "interview"})
|
| 360 |
):
|
|
|
|
| 34 |
"engine_family": "qualitative_engine",
|
| 35 |
"template_profile": "qualitative_materials",
|
| 36 |
"material_profile": "qualitative_materials",
|
| 37 |
+
"research_material_extensions": ("txt",),
|
| 38 |
"material_routing": {
|
| 39 |
"structured": "data",
|
| 40 |
"text": "interview",
|
|
|
|
| 52 |
"engine_family": "quantitative_engine",
|
| 53 |
"template_profile": "survey_quantitative_data",
|
| 54 |
"material_profile": "survey_quantitative_data",
|
| 55 |
+
"research_material_extensions": ("csv",),
|
| 56 |
"material_routing": {
|
| 57 |
"structured": "data",
|
| 58 |
"text": "reference",
|
|
|
|
| 344 |
engine_family = str(capability.get("engine_family") or "").strip()
|
| 345 |
template_profile = str(capability.get("template_profile") or "").strip()
|
| 346 |
material_profile = str(capability.get("material_profile") or "").strip()
|
| 347 |
+
research_material_extensions = tuple(
|
| 348 |
+
str(item or "").strip().casefold()
|
| 349 |
+
for item in (capability.get("research_material_extensions") or ())
|
| 350 |
+
if str(item or "").strip()
|
| 351 |
+
)
|
| 352 |
material_routing = dict(capability.get("material_routing") or {})
|
| 353 |
routing_keys = {
|
| 354 |
"structured", "text", "document_reference", "document_research",
|
|
|
|
| 362 |
or not engine_family
|
| 363 |
or not template_profile
|
| 364 |
or not material_profile
|
| 365 |
+
or len(research_material_extensions) != 1
|
| 366 |
+
or research_material_extensions[0] not in {"txt", "csv"}
|
| 367 |
or set(material_routing) != routing_keys
|
| 368 |
or not routing_values.issubset({"reference", "data", "interview"})
|
| 369 |
):
|
core/qualitative_engine.py
CHANGED
|
@@ -664,6 +664,23 @@ Rules:
|
|
| 664 |
{schema}
|
| 665 |
"""
|
| 666 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
def _qualitative_packet_validation_error(self, data, *, has_user_data, user_intent=None):
|
| 668 |
"""Return a bounded local validation reason, or an empty string when safe.
|
| 669 |
|
|
@@ -739,7 +756,7 @@ Rules:
|
|
| 739 |
continue
|
| 740 |
quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
|
| 741 |
normalized_quote = re.sub(r"\s+", " ", quote).strip().casefold()
|
| 742 |
-
if not
|
| 743 |
continue
|
| 744 |
seen_quotes.add(normalized_quote)
|
| 745 |
retained += 1
|
|
@@ -783,12 +800,13 @@ Rules:
|
|
| 783 |
else:
|
| 784 |
retry_prompt = base_prompt + (
|
| 785 |
"\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
|
| 786 |
-
"JSON/schema/content validation. Generate a fresh complete
|
| 787 |
-
"packet from the ORIGINAL inputs above. Do not repair, continue,
|
| 788 |
-
"the previous response. Return exactly one complete JSON object matching
|
| 789 |
-
"schema, with no Markdown or explanatory prose.
|
| 790 |
-
"
|
| 791 |
-
"
|
|
|
|
| 792 |
)
|
| 793 |
response = self.agent.call_deepseek(
|
| 794 |
system_prompt,
|
|
@@ -917,7 +935,7 @@ Rules:
|
|
| 917 |
if evidence_role not in {"typical", "deviant"}:
|
| 918 |
evidence_role = "typical"
|
| 919 |
normalized_quote = re.sub(r"\s+", " ", quote).strip().lower()
|
| 920 |
-
if not
|
| 921 |
continue
|
| 922 |
seen_quotes.add(normalized_quote)
|
| 923 |
tag = f"QUOTE-{len(clean_evidence) + 1:02d}"
|
|
|
|
| 664 |
{schema}
|
| 665 |
"""
|
| 666 |
|
| 667 |
+
@staticmethod
|
| 668 |
+
def _quote_has_substantive_content(value):
|
| 669 |
+
"""Language-aware minimum-content gate for participant quotations.
|
| 670 |
+
|
| 671 |
+
The previous whitespace-token rule rejected ordinary Chinese sentences
|
| 672 |
+
because an entire CJK sentence can contain zero spaces. Article Agent
|
| 673 |
+
supports Chinese/English/Russian/Kazakh, so accept either four or more
|
| 674 |
+
whitespace-delimited tokens or a substantive CJK character sequence.
|
| 675 |
+
"""
|
| 676 |
+
quote = re.sub(r"\s+", " ", str(value or "")).strip()
|
| 677 |
+
if not quote:
|
| 678 |
+
return False
|
| 679 |
+
if len(quote.split()) >= 4:
|
| 680 |
+
return True
|
| 681 |
+
cjk_chars = re.findall(r"[\u3400-\u4dbf\u4e00-\u9fff]", quote)
|
| 682 |
+
return len(cjk_chars) >= 8
|
| 683 |
+
|
| 684 |
def _qualitative_packet_validation_error(self, data, *, has_user_data, user_intent=None):
|
| 685 |
"""Return a bounded local validation reason, or an empty string when safe.
|
| 686 |
|
|
|
|
| 756 |
continue
|
| 757 |
quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
|
| 758 |
normalized_quote = re.sub(r"\s+", " ", quote).strip().casefold()
|
| 759 |
+
if not self._quote_has_substantive_content(quote) or normalized_quote in seen_quotes:
|
| 760 |
continue
|
| 761 |
seen_quotes.add(normalized_quote)
|
| 762 |
retained += 1
|
|
|
|
| 800 |
else:
|
| 801 |
retry_prompt = base_prompt + (
|
| 802 |
"\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
|
| 803 |
+
f"JSON/schema/content validation with reason={last_reason}. Generate a fresh complete "
|
| 804 |
+
"qualitative evidence packet from the ORIGINAL inputs above. Do not repair, continue, "
|
| 805 |
+
"quote, or rely on the previous response. Return exactly one complete JSON object matching "
|
| 806 |
+
"the required schema, with no Markdown or explanatory prose. The evidence array must contain "
|
| 807 |
+
"the required complete evidence objects, each with non-empty theme, concept, quote, stage, "
|
| 808 |
+
"case_label, and evidence_role fields. Preserve every evidence-fidelity rule from the original "
|
| 809 |
+
"request, including verbatim quotation requirements when user material was supplied."
|
| 810 |
)
|
| 811 |
response = self.agent.call_deepseek(
|
| 812 |
system_prompt,
|
|
|
|
| 935 |
if evidence_role not in {"typical", "deviant"}:
|
| 936 |
evidence_role = "typical"
|
| 937 |
normalized_quote = re.sub(r"\s+", " ", quote).strip().lower()
|
| 938 |
+
if not self._quote_has_substantive_content(quote) or normalized_quote in seen_quotes:
|
| 939 |
continue
|
| 940 |
seen_quotes.add(normalized_quote)
|
| 941 |
tag = f"QUOTE-{len(clean_evidence) + 1:02d}"
|
i18n.py
CHANGED
|
@@ -277,21 +277,29 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 277 |
"asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
|
| 278 |
"download_asq_template": "📥 一键下载 TXT 访谈模板",
|
| 279 |
"start_asq": "🚀 启动 ASQ 模式生成",
|
| 280 |
-
"research_materials_label": "研究资料(可选)",
|
| 281 |
-
"research_materials_help": "
|
|
|
|
|
|
|
|
|
|
| 282 |
"drop_research_materials": "将研究资料拖到这里",
|
| 283 |
-
"limit_research_materials": "每个文件最大 1 GB ·
|
| 284 |
"material_document_use_label": "如何使用上传的 PDF/DOC/DOCX 文件?",
|
| 285 |
"material_use_reference": "作为参考文献资料",
|
| 286 |
"material_use_research": "作为研究资料",
|
| 287 |
"material_reference_limit_exceeded": "参考文献最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
|
| 288 |
"material_research_limit_exceeded": "研究资料最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
|
| 289 |
"material_parse_failed": "文件“{filename}”无法被系统正确解析。请先转换为可识别格式后重新上传(建议:{formats})。",
|
|
|
|
| 290 |
"material_reference_convert_formats": "PDF、DOCX 或 TXT",
|
| 291 |
"material_research_convert_formats": "PDF、DOCX、TXT、CSV 或 XLSX",
|
| 292 |
"material_research_content_insufficient": "已选择的研究资料可解析内容不足,系统无法可靠识别。请补充内容或转换为可正确识别的格式后重新上传。",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
"material_preflight_blocked": "研究资料预检未通过,请先处理上方文件问题;系统尚未创建任务或预留额度。",
|
| 294 |
-
"detected_method_label": "研究设计(可调整)",
|
| 295 |
"method_detection_explanation": "默认为质性研究;生成前可根据需要调整为问卷量化研究。",
|
| 296 |
"method_confirm_label": "调整研究设计",
|
| 297 |
"method_confirm_placeholder": "请选择研究设计",
|
|
@@ -532,21 +540,29 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 532 |
"asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.",
|
| 533 |
"download_asq_template": "📥 Download TXT Interview Template",
|
| 534 |
"start_asq": "🚀 Start ASQ Generation",
|
| 535 |
-
"research_materials_label": "Research Materials (Optional)",
|
| 536 |
-
"research_materials_help": "
|
|
|
|
|
|
|
|
|
|
| 537 |
"drop_research_materials": "Drag research materials here",
|
| 538 |
-
"limit_research_materials": "Up to 1 GB per file ·
|
| 539 |
"material_document_use_label": "How should SmartPaper use the uploaded PDF/DOC/DOCX files?",
|
| 540 |
"material_use_reference": "Use as reference literature",
|
| 541 |
"material_use_research": "Use as research materials",
|
| 542 |
"material_reference_limit_exceeded": "You can select at most {limit} reference files. Change file roles or remove extra files.",
|
| 543 |
"material_research_limit_exceeded": "You can select at most {limit} research-material files. Change file roles or remove extra files.",
|
| 544 |
"material_parse_failed": "The system cannot reliably parse “{filename}”. Convert it to a supported readable format and upload it again (recommended: {formats}).",
|
|
|
|
| 545 |
"material_reference_convert_formats": "PDF, DOCX, or TXT",
|
| 546 |
"material_research_convert_formats": "PDF, DOCX, TXT, CSV, or XLSX",
|
| 547 |
"material_research_content_insufficient": "The selected research materials do not contain enough parseable content for reliable use. Add content or convert the files to a readable format and upload them again.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
"material_preflight_blocked": "Research-material preflight did not pass. Resolve the file issues above first; no task or quota reservation has been created.",
|
| 549 |
-
"detected_method_label": "Research Design (Adjustable)",
|
| 550 |
"method_detection_explanation": "Qualitative Research is selected by default. Before generation, you can adjust the Research Design to Survey-Based Quantitative Research.",
|
| 551 |
"method_confirm_label": "Adjust Research Design",
|
| 552 |
"method_confirm_placeholder": "Select a Research Design",
|
|
@@ -787,21 +803,29 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 787 |
"asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
|
| 788 |
"download_asq_template": "📥 Скачать TXT-шаблон интервью",
|
| 789 |
"start_asq": "🚀 Запустить формирование в режиме ASQ",
|
| 790 |
-
"research_materials_label": "Материалы исследования (необязательно)",
|
| 791 |
-
"research_materials_help": "
|
|
|
|
|
|
|
|
|
|
| 792 |
"drop_research_materials": "Перетащите исследовательские материалы сюда",
|
| 793 |
-
"limit_research_materials": "До 1 ГБ на файл ·
|
| 794 |
"material_document_use_label": "Как использовать загруженные файлы PDF/DOC/DOCX?",
|
| 795 |
"material_use_reference": "Как научные источники",
|
| 796 |
"material_use_research": "Как исследовательские материалы",
|
| 797 |
"material_reference_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве научных источников. Измените назначение файлов или удалите лишние.",
|
| 798 |
"material_research_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве исследовательских материалов. Измените назначение файлов или удалите лишние.",
|
| 799 |
"material_parse_failed": "Система не может корректно распознать файл «{filename}». Преобразуйте его в поддерживаемый читаемый формат и загрузите снова (рекомендуется: {formats}).",
|
|
|
|
| 800 |
"material_reference_convert_formats": "PDF, DOCX или TXT",
|
| 801 |
"material_research_convert_formats": "PDF, DOCX, TXT, CSV или XLSX",
|
| 802 |
"material_research_content_insufficient": "В выбранных исследовательских материалах недостаточно корректно распознаваемого содержимого. Дополните материалы или преобразуйте файлы в читаемый формат и загрузите их снова.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
"material_preflight_blocked": "Предварительная проверка исследовательских материалов не пройдена. Сначала устраните указанные выше проблемы; задача и резерв квоты ещё не созданы.",
|
| 804 |
-
"detected_method_label": "Дизайн исследования (можно изменить)",
|
| 805 |
"method_detection_explanation": "По умолчанию выбрано качественное исследование. До начала генерации дизайн можно изменить на количественное исследование на основе опроса.",
|
| 806 |
"method_confirm_label": "Изменить дизайн исследования",
|
| 807 |
"method_confirm_placeholder": "Выберите дизайн исследования",
|
|
@@ -1042,21 +1066,29 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 1042 |
"asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
|
| 1043 |
"download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
|
| 1044 |
"start_asq": "🚀 ASQ режимінде дайындауды бастау",
|
| 1045 |
-
"research_materials_label": "Зерттеу материалдары (міндетті емес)",
|
| 1046 |
-
"research_materials_help": "
|
|
|
|
|
|
|
|
|
|
| 1047 |
"drop_research_materials": "Зерттеу материалдарын осы жерге сүйреп әкеліңіз",
|
| 1048 |
-
"limit_research_materials": "Әр файлға 1 ГБ дейін ·
|
| 1049 |
"material_document_use_label": "Жүктелген PDF/DOC/DOCX файлдарын қалай пайдалану керек?",
|
| 1050 |
"material_use_reference": "Ғылыми дереккөз ретінде",
|
| 1051 |
"material_use_research": "Зерттеу материалы ретінде",
|
| 1052 |
"material_reference_limit_exceeded": "Ғылыми дереккөз ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
|
| 1053 |
"material_research_limit_exceeded": "Зерттеу материалы ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
|
| 1054 |
"material_parse_failed": "Жүйе «{filename}» файлын сенімді түрде тани алмады. Оны оқылатын қолдау көрсетілетін пішімге түрлендіріп, қайта жүктеңіз (ұсынылады: {formats}).",
|
|
|
|
| 1055 |
"material_reference_convert_formats": "PDF, DOCX немесе TXT",
|
| 1056 |
"material_research_convert_formats": "PDF, DOCX, TXT, CSV немесе XLSX",
|
| 1057 |
"material_research_content_insufficient": "Таңдалған зерттеу материалдарында сенімді пайдалануға жеткілікті танылатын мазмұн жоқ. Мазмұнды толықтырыңыз немесе файлдарды оқылатын пішімге түрлендіріп, қайта жүктеңіз.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1058 |
"material_preflight_blocked": "Зерттеу материалдарының алдын ала тексеруі өтпеді. Алдымен жоғарыдағы файл мәселелерін түзетіңіз; тапсырма да, квота резерві де әлі жасалған жоқ.",
|
| 1059 |
-
"detected_method_label": "Зерттеу дизайны (өзгертуге болады)",
|
| 1060 |
"method_detection_explanation": "Әдепкі бойынша сапалық зерттеу таңдалады. Генерацияны бастамас бұрын зерттеу дизайнын сауалнамаға негізделген сандық зерттеуге өзгертуге болады.",
|
| 1061 |
"method_confirm_label": "Зерттеу дизайнын өзгерту",
|
| 1062 |
"method_confirm_placeholder": "Зерттеу дизайнын таңдаңыз",
|
|
|
|
| 277 |
"asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
|
| 278 |
"download_asq_template": "📥 一键下载 TXT 访谈模板",
|
| 279 |
"start_asq": "🚀 启动 ASQ 模式生成",
|
| 280 |
+
"research_materials_label": "📂 研究资料(可选)",
|
| 281 |
+
"research_materials_help": "研究资料必须使用上方推荐模板。",
|
| 282 |
+
"research_materials_help_qualitative": "质性研究仅接受 TXT,且内容必须符合上方推荐模板。",
|
| 283 |
+
"research_materials_help_survey_path": "问卷量化研究仅接受 CSV,且内容必须符合上方推荐模板。",
|
| 284 |
+
"research_materials_help_undetermined": "请先确认研究设计。",
|
| 285 |
"drop_research_materials": "将研究资料拖到这里",
|
| 286 |
+
"limit_research_materials": "每个文件最大 1 GB · 仅接受当前研究设计对应模板格式",
|
| 287 |
"material_document_use_label": "如何使用上传的 PDF/DOC/DOCX 文件?",
|
| 288 |
"material_use_reference": "作为参考文献资料",
|
| 289 |
"material_use_research": "作为研究资料",
|
| 290 |
"material_reference_limit_exceeded": "参考文献最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
|
| 291 |
"material_research_limit_exceeded": "研究资料最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
|
| 292 |
"material_parse_failed": "文件“{filename}”无法被系统正确解析。请先转换为可识别格式后重新上传(建议:{formats})。",
|
| 293 |
+
"material_template_parse_failed": "研究资料“{filename}”无法按推荐模板正确识别。请使用上方推荐模板整理为 {format} 文件后重新上传。",
|
| 294 |
"material_reference_convert_formats": "PDF、DOCX 或 TXT",
|
| 295 |
"material_research_convert_formats": "PDF、DOCX、TXT、CSV 或 XLSX",
|
| 296 |
"material_research_content_insufficient": "已选择的研究资料可解析内容不足,系统无法可靠识别。请补充内容或转换为可正确识别的格式后重新上传。",
|
| 297 |
+
"material_file_size_exceeded": "研究资料“{filename}”文件过大。单个文件不得超过 {limit_mb} MB,请精简后重新上传。",
|
| 298 |
+
"material_total_size_exceeded": "研究资料总大小超过 {limit_mb} MB,请减少文件数量或精简内容后重新上传。",
|
| 299 |
+
"material_bundle_capacity_exceeded": "研究资料内容超过当前研究设计可可靠处理的容量。请精简材料后重新上传,系统不会静默截断。",
|
| 300 |
+
"material_template_example_not_replaced": "研究资料“{filename}”仍是未修改的推荐模板示例。请用真实研究结果替换示例内容后重新上传。",
|
| 301 |
"material_preflight_blocked": "研究资料预检未通过,请先处理上方文件问题;系统尚未创建任务或预留额度。",
|
| 302 |
+
"detected_method_label": "🧭 研究设计(可调整)",
|
| 303 |
"method_detection_explanation": "默认为质性研究;生成前可根据需要调整为问卷量化研究。",
|
| 304 |
"method_confirm_label": "调整研究设计",
|
| 305 |
"method_confirm_placeholder": "请选择研究设计",
|
|
|
|
| 540 |
"asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.",
|
| 541 |
"download_asq_template": "📥 Download TXT Interview Template",
|
| 542 |
"start_asq": "🚀 Start ASQ Generation",
|
| 543 |
+
"research_materials_label": "📂 Research Materials (Optional)",
|
| 544 |
+
"research_materials_help": "Research materials must follow the Recommended Template above.",
|
| 545 |
+
"research_materials_help_qualitative": "Qualitative Research accepts TXT only, following the Recommended Template above.",
|
| 546 |
+
"research_materials_help_survey_path": "Survey-Based Quantitative Research accepts CSV only, following the Recommended Template above.",
|
| 547 |
+
"research_materials_help_undetermined": "Confirm the Research Design first.",
|
| 548 |
"drop_research_materials": "Drag research materials here",
|
| 549 |
+
"limit_research_materials": "Up to 1 GB per file · only the template format for the current Research Design",
|
| 550 |
"material_document_use_label": "How should SmartPaper use the uploaded PDF/DOC/DOCX files?",
|
| 551 |
"material_use_reference": "Use as reference literature",
|
| 552 |
"material_use_research": "Use as research materials",
|
| 553 |
"material_reference_limit_exceeded": "You can select at most {limit} reference files. Change file roles or remove extra files.",
|
| 554 |
"material_research_limit_exceeded": "You can select at most {limit} research-material files. Change file roles or remove extra files.",
|
| 555 |
"material_parse_failed": "The system cannot reliably parse “{filename}”. Convert it to a supported readable format and upload it again (recommended: {formats}).",
|
| 556 |
+
"material_template_parse_failed": "Research material “{filename}” does not match the Recommended Template. Reformat it as a {format} file using the template above and upload it again.",
|
| 557 |
"material_reference_convert_formats": "PDF, DOCX, or TXT",
|
| 558 |
"material_research_convert_formats": "PDF, DOCX, TXT, CSV, or XLSX",
|
| 559 |
"material_research_content_insufficient": "The selected research materials do not contain enough parseable content for reliable use. Add content or convert the files to a readable format and upload them again.",
|
| 560 |
+
"material_file_size_exceeded": "Research material “{filename}” is too large. Each file must be no larger than {limit_mb} MB. Reduce it and upload again.",
|
| 561 |
+
"material_total_size_exceeded": "The total research-material size exceeds {limit_mb} MB. Reduce the number or size of files and upload again.",
|
| 562 |
+
"material_bundle_capacity_exceeded": "The research materials exceed the reliable processing capacity for this research design. Reduce the materials and upload again; the system will not silently truncate them.",
|
| 563 |
+
"material_template_example_not_replaced": "Research material “{filename}” is still the unchanged Recommended Template example. Replace the sample values with your actual research results and upload again.",
|
| 564 |
"material_preflight_blocked": "Research-material preflight did not pass. Resolve the file issues above first; no task or quota reservation has been created.",
|
| 565 |
+
"detected_method_label": "🧭 Research Design (Adjustable)",
|
| 566 |
"method_detection_explanation": "Qualitative Research is selected by default. Before generation, you can adjust the Research Design to Survey-Based Quantitative Research.",
|
| 567 |
"method_confirm_label": "Adjust Research Design",
|
| 568 |
"method_confirm_placeholder": "Select a Research Design",
|
|
|
|
| 803 |
"asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
|
| 804 |
"download_asq_template": "📥 Скачать TXT-шаблон интервью",
|
| 805 |
"start_asq": "🚀 Запустить формирование в режиме ASQ",
|
| 806 |
+
"research_materials_label": "📂 Материалы исследования (необязательно)",
|
| 807 |
+
"research_materials_help": "Материалы исследования должны соответствовать рекомендуемому шаблону выше.",
|
| 808 |
+
"research_materials_help_qualitative": "Для качественного исследования принимаются только TXT-файлы по рекомендуемому шаблону выше.",
|
| 809 |
+
"research_materials_help_survey_path": "Для количественного исследования на основе опроса принимаются только CSV-файлы по рекомендуемому шаблону выше.",
|
| 810 |
+
"research_materials_help_undetermined": "Сначала подтвердите дизайн исследования.",
|
| 811 |
"drop_research_materials": "Перетащите исследовательские материалы сюда",
|
| 812 |
+
"limit_research_materials": "До 1 ГБ на файл · только формат шаблона текущего дизайна исследования",
|
| 813 |
"material_document_use_label": "Как использовать загруженные файлы PDF/DOC/DOCX?",
|
| 814 |
"material_use_reference": "Как научные источники",
|
| 815 |
"material_use_research": "Как исследовательские материалы",
|
| 816 |
"material_reference_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве научных источников. Измените назначение файлов или удалите лишние.",
|
| 817 |
"material_research_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве исследовательских материалов. Измените назначение файлов или удалите лишние.",
|
| 818 |
"material_parse_failed": "Система не может корректно распознать файл «{filename}». Преобразуйте его в поддерживаемый читаемый формат и загрузите снова (рекомендуется: {formats}).",
|
| 819 |
+
"material_template_parse_failed": "Материал «{filename}» не соответствует рекомендуемому шаблону. Оформите его по шаблону выше как файл {format} и загрузите снова.",
|
| 820 |
"material_reference_convert_formats": "PDF, DOCX или TXT",
|
| 821 |
"material_research_convert_formats": "PDF, DOCX, TXT, CSV или XLSX",
|
| 822 |
"material_research_content_insufficient": "В выбранных исследовательских материалах недостаточно корректно распознаваемого содержимого. Дополните материалы или преобразуйте файлы в читаемый формат и загрузите их снова.",
|
| 823 |
+
"material_file_size_exceeded": "Файл исследовательского материала «{filename}» слишком велик. Размер одного файла не должен превышать {limit_mb} МБ. Сократите его и загрузите снова.",
|
| 824 |
+
"material_total_size_exceeded": "Общий размер исследовательских материалов превышает {limit_mb} МБ. Уменьшите количество или размер файлов и загрузите снова.",
|
| 825 |
+
"material_bundle_capacity_exceeded": "Объём исследовательских материалов превышает надёжную ёмкость обработки для выбранного дизайна. Сократите материалы и загрузите снова; система не будет молча обрезать их.",
|
| 826 |
+
"material_template_example_not_replaced": "Материал «{filename}» всё ещё содержит неизменённый пример рекомендуемого шаблона. Замените пример своими фактическими результатами и загрузите файл снова.",
|
| 827 |
"material_preflight_blocked": "Предварительная проверка исследовательских материалов не пройдена. Сначала устраните указанные выше проблемы; задача и резерв квоты ещё не созданы.",
|
| 828 |
+
"detected_method_label": "🧭 Дизайн исследования (можно изменить)",
|
| 829 |
"method_detection_explanation": "По умолчанию выбрано качественное исследование. До начала генерации дизайн можно изменить на количественное исследование на основе опроса.",
|
| 830 |
"method_confirm_label": "Изменить дизайн исследования",
|
| 831 |
"method_confirm_placeholder": "Выберите дизайн исследования",
|
|
|
|
| 1066 |
"asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
|
| 1067 |
"download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
|
| 1068 |
"start_asq": "🚀 ASQ режимінде дайындауды бастау",
|
| 1069 |
+
"research_materials_label": "📂 Зерттеу материалдары (міндетті емес)",
|
| 1070 |
+
"research_materials_help": "Зерттеу материалдары жоғарыдағы ұсынылатын үлгіге сәйкес болуы тиіс.",
|
| 1071 |
+
"research_materials_help_qualitative": "Сапалық зерттеу үшін жоғарыдағы ұсынылатын үлгіге сәйкес тек TXT файлы қабылданады.",
|
| 1072 |
+
"research_materials_help_survey_path": "Сауалнамаға негізделген сандық зерттеу үшін жоғарыдағы ұсынылатын үлгіге сәйкес тек CSV файлы қабылданады.",
|
| 1073 |
+
"research_materials_help_undetermined": "Алдымен зерттеу дизайнын растаңыз.",
|
| 1074 |
"drop_research_materials": "Зерттеу материалдарын осы жерге сүйреп әкеліңіз",
|
| 1075 |
+
"limit_research_materials": "Әр файлға 1 ГБ дейін · тек ағымдағы зерттеу дизайнының үлгі форматы",
|
| 1076 |
"material_document_use_label": "Жүктелген PDF/DOC/DOCX файлдарын қалай пайдалану керек?",
|
| 1077 |
"material_use_reference": "Ғылыми дереккөз ретінде",
|
| 1078 |
"material_use_research": "Зерттеу материалы ретінде",
|
| 1079 |
"material_reference_limit_exceeded": "Ғылыми дереккөз ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
|
| 1080 |
"material_research_limit_exceeded": "Зерттеу материалы ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
|
| 1081 |
"material_parse_failed": "Жүйе «{filename}» файлын сенімді түрде тани алмады. Оны оқылатын қолдау көрсетілетін пішімге түрлендіріп, қайта жүктеңіз (ұсынылады: {formats}).",
|
| 1082 |
+
"material_template_parse_failed": "«{filename}» зерттеу материалы ұсынылатын үлгіге сәйкес емес. Оны жоғарыдағы үлгімен {format} файлы ретінде қайта рәсімдеп, қайта жүктеңіз.",
|
| 1083 |
"material_reference_convert_formats": "PDF, DOCX немесе TXT",
|
| 1084 |
"material_research_convert_formats": "PDF, DOCX, TXT, CSV немесе XLSX",
|
| 1085 |
"material_research_content_insufficient": "Таңдалған зерттеу материалдарында сенімді пайдалануға жеткілікті танылатын мазмұн жоқ. Мазмұнды толықтырыңыз немесе файлдарды оқылатын пішімге түрлендіріп, қайта жүктеңіз.",
|
| 1086 |
+
"material_file_size_exceeded": "«{filename}» зерттеу материалының файлы тым үлкен. Бір файл {limit_mb} МБ-тан аспауы тиіс. Қысқартып, қайта жүктеңіз.",
|
| 1087 |
+
"material_total_size_exceeded": "Зерттеу материалдарының жалпы көлемі {limit_mb} МБ-тан асады. Файл санын немесе көлемін азайтып, қайта жүктеңіз.",
|
| 1088 |
+
"material_bundle_capacity_exceeded": "Зерттеу материалдарының көлемі таңдалған дизайн үшін сенімді өңдеу шегінен асады. Материалдарды қысқартып, қайта жүктеңіз; жүйе оларды үнсіз қысқартпайды.",
|
| 1089 |
+
"material_template_example_not_replaced": "«{filename}» материалы әлі де өзгертілмеген ұсынылатын үлгі мысалы болып тұр. Үлгі мәндерін нақты зерттеу нәтижелеріңізбен ауыстырып, қайта жүктеңіз.",
|
| 1090 |
"material_preflight_blocked": "Зерттеу материалдарының алдын ала тексеруі өтпеді. Алдымен жоғарыдағы файл мәселелерін түзетіңіз; тапсырма да, квота резерві де әлі жасалған жоқ.",
|
| 1091 |
+
"detected_method_label": "🧭 Зерттеу дизайны (өзгертуге болады)",
|
| 1092 |
"method_detection_explanation": "Әдепкі бойынша сапалық зерттеу таңдалады. Генерацияны бастамас бұрын зерттеу дизайнын сауалнамаға негізделген сандық зерттеуге өзгертуге болады.",
|
| 1093 |
"method_confirm_label": "Зерттеу дизайнын өзгерту",
|
| 1094 |
"method_confirm_placeholder": "Зерттеу дизайнын таңдаңыз",
|
utils/file_parser.py
CHANGED
|
@@ -6,9 +6,11 @@ an explicit validator that the UI can run before task creation/quota reservation
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import io
|
| 10 |
import logging
|
| 11 |
import os
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import BinaryIO, Iterable
|
| 14 |
|
|
@@ -187,6 +189,152 @@ def validate_file_parseable(file, max_chars=4000) -> dict:
|
|
| 187 |
}
|
| 188 |
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
def extract_text_from_files(uploaded_files: Iterable, max_chars=None):
|
| 191 |
if max_chars is None:
|
| 192 |
max_chars = APP_CONFIG.get("max_extract_chars", 150000)
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import hashlib
|
| 10 |
import io
|
| 11 |
import logging
|
| 12 |
import os
|
| 13 |
+
import re
|
| 14 |
from pathlib import Path
|
| 15 |
from typing import BinaryIO, Iterable
|
| 16 |
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
_STRICT_RESEARCH_TEMPLATE_EXTENSIONS = {
|
| 195 |
+
"qualitative_materials": ".txt",
|
| 196 |
+
"survey_quantitative_data": ".csv",
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _validate_qualitative_template_text(text: str) -> None:
|
| 201 |
+
value = str(text or "").strip()
|
| 202 |
+
if not value:
|
| 203 |
+
raise FileParseError("template_content_empty")
|
| 204 |
+
metadata_lines = re.findall(r"(?m)^\s*\[[^\]\n]{2,100}\]\s*[::]\s*\S+", value)
|
| 205 |
+
dialogue_lines = [
|
| 206 |
+
line.strip()
|
| 207 |
+
for line in value.splitlines()
|
| 208 |
+
if line.strip() and not line.lstrip().startswith("[") and re.search(r"[::]", line)
|
| 209 |
+
]
|
| 210 |
+
if not re.search(r"\bINT-\d+\b", value, flags=re.I):
|
| 211 |
+
raise FileParseError("template_missing_interview_id")
|
| 212 |
+
if len(metadata_lines) < 3 or len(dialogue_lines) < 2:
|
| 213 |
+
raise FileParseError("template_structure_mismatch")
|
| 214 |
+
# The downloaded template contains explicit placeholders. If any remain,
|
| 215 |
+
# the file is not yet research material and must not enter generation.
|
| 216 |
+
if re.search(r"\[(?:在这里输入|Enter\b|Введите\b|[^\]\n]*осында енгізіңіз)", value, flags=re.I):
|
| 217 |
+
raise FileParseError("template_placeholders_not_replaced")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _validate_survey_template_text(text: str) -> None:
|
| 221 |
+
value = str(text or "").strip()
|
| 222 |
+
if not value:
|
| 223 |
+
raise FileParseError("template_content_empty")
|
| 224 |
+
lines = [line.strip() for line in value.splitlines() if line.strip()]
|
| 225 |
+
section_markers = [line for line in lines if line.startswith("---") and line.endswith("---")]
|
| 226 |
+
has_descriptive = any(",SD," in line and line.count(",") >= 4 for line in lines)
|
| 227 |
+
has_reliability = any("Cronbach's Alpha" in line and ",CR," in line and ",AVE" in line for line in lines)
|
| 228 |
+
has_hypothesis = any("Beta" in line and ("P-value" in line or "P-значение" in line or "P-мәні" in line) for line in lines)
|
| 229 |
+
if len(section_markers) < 3 or not (has_descriptive and has_reliability and has_hypothesis):
|
| 230 |
+
raise FileParseError("template_structure_mismatch")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _research_material_size_hint(file) -> int | None:
|
| 234 |
+
if isinstance(file, (str, os.PathLike, Path)):
|
| 235 |
+
try:
|
| 236 |
+
return int(Path(file).stat().st_size)
|
| 237 |
+
except OSError:
|
| 238 |
+
return None
|
| 239 |
+
value = getattr(file, "size", None)
|
| 240 |
+
try:
|
| 241 |
+
if value is not None:
|
| 242 |
+
return int(value)
|
| 243 |
+
except (TypeError, ValueError):
|
| 244 |
+
pass
|
| 245 |
+
try:
|
| 246 |
+
return int(file.getbuffer().nbytes)
|
| 247 |
+
except Exception:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def research_material_text_fingerprint(text: str) -> str:
|
| 252 |
+
"""Stable fingerprint for detecting an unchanged downloadable template."""
|
| 253 |
+
normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
| 254 |
+
normalized = "\n".join(line.rstrip() for line in normalized.splitlines()).strip()
|
| 255 |
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _strict_research_material_text(file, template_profile: str, *, max_bytes: int | None = None) -> tuple[str, int]:
|
| 259 |
+
profile = str(template_profile or "").strip().casefold()
|
| 260 |
+
expected_ext = _STRICT_RESEARCH_TEMPLATE_EXTENSIONS.get(profile)
|
| 261 |
+
if not expected_ext:
|
| 262 |
+
raise FileParseError("template_profile_unsupported")
|
| 263 |
+
filename = _name(file)
|
| 264 |
+
suffix = Path(filename).suffix.casefold()
|
| 265 |
+
if suffix != expected_ext:
|
| 266 |
+
raise FileParseError(f"template_extension_mismatch:expected={expected_ext}")
|
| 267 |
+
byte_limit = int(max_bytes or 0)
|
| 268 |
+
size_hint = _research_material_size_hint(file)
|
| 269 |
+
if byte_limit > 0 and size_hint is not None and size_hint > byte_limit:
|
| 270 |
+
raise FileParseError(f"research_material_file_too_large:{size_hint}:{byte_limit}")
|
| 271 |
+
try:
|
| 272 |
+
raw = _read_bytes(file)
|
| 273 |
+
if byte_limit > 0 and len(raw) > byte_limit:
|
| 274 |
+
raise FileParseError(f"research_material_file_too_large:{len(raw)}:{byte_limit}")
|
| 275 |
+
value = _decode_text_bytes(raw).strip()
|
| 276 |
+
finally:
|
| 277 |
+
if not isinstance(file, (str, os.PathLike, Path)):
|
| 278 |
+
try:
|
| 279 |
+
file.seek(0)
|
| 280 |
+
except Exception:
|
| 281 |
+
pass
|
| 282 |
+
if profile == "qualitative_materials":
|
| 283 |
+
_validate_qualitative_template_text(value)
|
| 284 |
+
elif profile == "survey_quantitative_data":
|
| 285 |
+
_validate_survey_template_text(value)
|
| 286 |
+
return value, len(raw)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def extract_research_material_content(file, template_profile: str, max_chars=None, *, max_bytes: int | None = None) -> str:
|
| 290 |
+
"""Read one strict research-material file under the selected template contract."""
|
| 291 |
+
value, _byte_size = _strict_research_material_text(file, template_profile, max_bytes=max_bytes)
|
| 292 |
+
if max_chars is None:
|
| 293 |
+
max_chars = APP_CONFIG.get("max_extract_chars", 150000)
|
| 294 |
+
max_chars = max(1, int(max_chars))
|
| 295 |
+
return value[:max_chars]
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def validate_research_material_template(
|
| 299 |
+
file,
|
| 300 |
+
template_profile: str,
|
| 301 |
+
max_chars=None,
|
| 302 |
+
*,
|
| 303 |
+
max_bytes: int | None = None,
|
| 304 |
+
disallowed_content_fingerprints: set[str] | None = None,
|
| 305 |
+
) -> dict:
|
| 306 |
+
"""Return an exact, UI-safe strict-template validation result.
|
| 307 |
+
|
| 308 |
+
Validation counts the full decoded text before any bundle truncation, so the
|
| 309 |
+
caller can reject oversized bundles before task creation/quota reservation.
|
| 310 |
+
"""
|
| 311 |
+
filename = os.path.basename(_name(file))
|
| 312 |
+
try:
|
| 313 |
+
content, byte_size = _strict_research_material_text(file, template_profile, max_bytes=max_bytes)
|
| 314 |
+
fingerprint = research_material_text_fingerprint(content)
|
| 315 |
+
if disallowed_content_fingerprints and fingerprint in disallowed_content_fingerprints:
|
| 316 |
+
raise FileParseError("template_example_not_replaced")
|
| 317 |
+
shown = content if max_chars is None else content[: max(1, int(max_chars))]
|
| 318 |
+
return {
|
| 319 |
+
"ok": True,
|
| 320 |
+
"filename": filename,
|
| 321 |
+
"text_chars": len(content),
|
| 322 |
+
"preview_chars": len(shown),
|
| 323 |
+
"byte_size": byte_size,
|
| 324 |
+
"fingerprint": fingerprint,
|
| 325 |
+
"error": "",
|
| 326 |
+
}
|
| 327 |
+
except Exception as exc:
|
| 328 |
+
return {
|
| 329 |
+
"ok": False,
|
| 330 |
+
"filename": filename,
|
| 331 |
+
"text_chars": 0,
|
| 332 |
+
"preview_chars": 0,
|
| 333 |
+
"byte_size": int(_research_material_size_hint(file) or 0),
|
| 334 |
+
"fingerprint": "",
|
| 335 |
+
"error": str(exc)[:200],
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
def extract_text_from_files(uploaded_files: Iterable, max_chars=None):
|
| 339 |
if max_chars is None:
|
| 340 |
max_chars = APP_CONFIG.get("max_extract_chars", 150000)
|