A5-R3-R7-R2C-R6B policy-driven keyword authority scoped deployment

#121
core/generation_runner.py CHANGED
@@ -32,7 +32,12 @@ from core.production_guard import build_validated_package, replace_abstract
32
  from core.qualitative_engine import get_qualitative_engine
33
  from core.quantitative_engine import get_quantitative_engine
34
  from core.rag_engine import initialize_rag
35
- from core.study_design_contract import build_user_research_intent
 
 
 
 
 
36
  from utils.file_parser import extract_text_from_files
37
 
38
  ProgressCallback = Callable[..., None]
@@ -1796,27 +1801,73 @@ class GenerationRunner:
1796
  return str(topic or "").strip(), str(keywords or "").strip()
1797
 
1798
  @staticmethod
1799
- def _canonical_quantitative_front_matter_keywords(mode: str, candidate_keywords, data_metadata: dict):
1800
- """Return user-authoritative keywords when available; otherwise normalize a legacy candidate.
 
 
 
 
 
1801
 
1802
- The name is retained for checkpoint compatibility, but R7-R2C-R5 applies
1803
- the same authority rule to quantitative and qualitative manuscripts.
1804
- """
1805
  meta = dict(data_metadata or {})
1806
  contract = dict(meta.get("study_design_contract") or {})
1807
- intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
1808
- authoritative = list(intent.get("source_keywords") or contract.get("source_keywords") or [])
1809
- source = authoritative if authoritative else list(candidate_keywords or [])
1810
  values = []
1811
- seen = set()
1812
- for item in source:
1813
- value = re.sub(r"\s+", " ", str(item or "")).strip()
1814
- key = value.casefold()
1815
- if value and key not in seen:
1816
- values.append(value)
1817
- seen.add(key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1818
  return values
1819
 
 
1820
  @staticmethod
1821
  def _replace_title(manuscript: str, title: str) -> str:
1822
  value = str(manuscript or "")
@@ -2014,7 +2065,7 @@ class GenerationRunner:
2014
  # distinguishing them from empirical construct roles.
2015
  intent_keywords = list(user_intent.get('source_keywords') or [])
2016
  if not intent_keywords:
2017
- intent_keywords = [seed.strip() for seed in re.split(r'[,;\n|]+', str(keywords or '')) if seed.strip()]
2018
  for seed in intent_keywords:
2019
  if 1 <= len(str(seed).split()) <= 10:
2020
  add(seed, 'user_intent_keyword', 'STRICT_CONSTRUCT')
@@ -3493,6 +3544,9 @@ class GenerationRunner:
3493
  topic, keywords, research_logic_contract, synthesis_result,
3494
  data_evidence=planning_data_md, mode=mode,
3495
  hypothesis_contract=hypothesis_contract,
 
 
 
3496
  )
3497
  working_abstract = str(working_front.get("abstract") or "").strip()
3498
  if hypothesis_ledger:
@@ -3522,6 +3576,9 @@ class GenerationRunner:
3522
  topic, keywords, research_logic_contract, synthesis_result,
3523
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
3524
  hypothesis_contract=hypothesis_contract,
 
 
 
3525
  )
3526
  working_abstract = str(working_front.get("abstract") or "").strip()
3527
  if hypothesis_ledger:
@@ -3852,6 +3909,9 @@ class GenerationRunner:
3852
  topic, keywords, research_logic_contract, synthesis_result,
3853
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
3854
  hypothesis_contract=hypothesis_contract,
 
 
 
3855
  )
3856
  candidate_abstract = str(working_front.get("abstract") or "").strip()
3857
  if hypothesis_ledger:
@@ -4078,6 +4138,9 @@ class GenerationRunner:
4078
  topic, keywords, research_logic_contract, synthesis_result,
4079
  data_evidence=planning_data_md, mode=mode,
4080
  hypothesis_contract=hypothesis_contract,
 
 
 
4081
  )
4082
  working_abstract = str(working_front.get("abstract") or "").strip()
4083
  if hypothesis_ledger:
@@ -4102,6 +4165,9 @@ class GenerationRunner:
4102
  topic, keywords, research_logic_contract, synthesis_result,
4103
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4104
  hypothesis_contract=hypothesis_contract,
 
 
 
4105
  )
4106
  working_abstract = str(working_front.get("abstract") or "").strip()
4107
  if hypothesis_ledger:
@@ -5243,6 +5309,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5243
  final_front = agent.generate_final_front_matter(
5244
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
5245
  hypothesis_contract=hypothesis_contract,
 
 
 
5246
  )
5247
  final_abstract = str(final_front.get("abstract") or "").strip()
5248
  if hypothesis_ledger:
@@ -5272,6 +5341,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5272
  final_front = agent.generate_final_front_matter(
5273
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
5274
  validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
 
 
 
5275
  )
5276
  final_abstract = str(final_front.get("abstract") or "").strip()
5277
  if hypothesis_ledger:
@@ -5319,12 +5391,13 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5319
  )
5320
  if candidate_title:
5321
  final_title = candidate_title
5322
- if 4 <= len(candidate_keywords) <= 6:
 
 
5323
  final_keywords = candidate_keywords
5324
  else:
5325
  logging.warning(
5326
- "Adaptive final keyword reconciliation returned nonpreferred count=%s; preserving validated Final Front Matter keywords.",
5327
- len(candidate_keywords),
5328
  )
5329
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
5330
  full_draft_en = self._replace_title(full_draft_en, final_title)
 
32
  from core.qualitative_engine import get_qualitative_engine
33
  from core.quantitative_engine import get_quantitative_engine
34
  from core.rag_engine import initialize_rag
35
+ from core.study_design_contract import (
36
+ build_user_research_intent,
37
+ keyword_policy_from_contract,
38
+ apply_keyword_policy,
39
+ parse_keyword_terms,
40
+ )
41
  from utils.file_parser import extract_text_from_files
42
 
43
  ProgressCallback = Callable[..., None]
 
1801
  return str(topic or "").strip(), str(keywords or "").strip()
1802
 
1803
  @staticmethod
1804
+ def _resolved_front_matter_keyword_policy(data_metadata: dict):
1805
+ """Resolve one policy object for Working/Final/retry/resume/fallback paths."""
1806
+ meta = dict(data_metadata or {})
1807
+ contract = dict(meta.get("study_design_contract") or meta.get("front_matter_contract") or {})
1808
+ if not contract and isinstance(meta.get("user_research_intent"), dict):
1809
+ contract = {"user_research_intent": dict(meta.get("user_research_intent") or {})}
1810
+ return keyword_policy_from_contract(contract)
1811
 
1812
+ @staticmethod
1813
+ def _front_matter_keyword_fallback_terms(data_metadata: dict):
1814
+ """Derive recoverable generated keywords only from frozen study-design authority."""
1815
  meta = dict(data_metadata or {})
1816
  contract = dict(meta.get("study_design_contract") or {})
 
 
 
1817
  values = []
1818
+ quant = dict(contract.get("quantitative") or {})
1819
+ for row in quant.get("constructs") or []:
1820
+ if isinstance(row, dict):
1821
+ label = re.sub(r"\s+", " ", str(row.get("label") or "")).strip()
1822
+ if label:
1823
+ values.append(label)
1824
+ qual = dict(contract.get("qualitative") or {})
1825
+ if qual:
1826
+ for candidate in (
1827
+ qual.get("phenomenon"),
1828
+ qual.get("design_tradition"),
1829
+ ):
1830
+ value = re.sub(r"\s+", " ", str(candidate or "")).strip()
1831
+ if value:
1832
+ values.append(value)
1833
+ for theme in meta.get("themes") or []:
1834
+ if isinstance(theme, dict):
1835
+ value = re.sub(r"\s+", " ", str(theme.get("title") or "")).strip()
1836
+ if value:
1837
+ values.append(value)
1838
+ return parse_keyword_terms(values, explicit_user=False)
1839
+
1840
+ @staticmethod
1841
+ def _user_authoritative_front_matter_keywords(data_metadata: dict):
1842
+ """Return explicit user keywords only when the resolved policy says USER_EXPLICIT."""
1843
+ meta = dict(data_metadata or {})
1844
+ contract = dict(meta.get("study_design_contract") or meta.get("front_matter_contract") or {})
1845
+ intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
1846
+ policy = keyword_policy_from_contract(contract)
1847
+ if str(policy.get("authority_source") or "").upper() != "USER_EXPLICIT":
1848
+ return []
1849
+ return apply_keyword_policy(
1850
+ intent.get("source_keywords") or [],
1851
+ policy,
1852
+ authoritative=intent.get("source_keywords") or [],
1853
+ )
1854
+
1855
+ @staticmethod
1856
+ def _canonical_quantitative_front_matter_keywords(mode: str, candidate_keywords, data_metadata: dict):
1857
+ """Apply one keyword policy across quantitative/qualitative and all product tiers."""
1858
+ meta = dict(data_metadata or {})
1859
+ contract = dict(meta.get("study_design_contract") or meta.get("front_matter_contract") or {})
1860
+ intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
1861
+ policy = keyword_policy_from_contract(contract)
1862
+ authoritative = list(intent.get("source_keywords") or [])
1863
+ values = apply_keyword_policy(
1864
+ candidate_keywords,
1865
+ policy,
1866
+ authoritative=authoritative if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" else None,
1867
+ )
1868
  return values
1869
 
1870
+
1871
  @staticmethod
1872
  def _replace_title(manuscript: str, title: str) -> str:
1873
  value = str(manuscript or "")
 
2065
  # distinguishing them from empirical construct roles.
2066
  intent_keywords = list(user_intent.get('source_keywords') or [])
2067
  if not intent_keywords:
2068
+ intent_keywords = parse_keyword_terms(keywords, explicit_user=True)
2069
  for seed in intent_keywords:
2070
  if 1 <= len(str(seed).split()) <= 10:
2071
  add(seed, 'user_intent_keyword', 'STRICT_CONSTRUCT')
 
3544
  topic, keywords, research_logic_contract, synthesis_result,
3545
  data_evidence=planning_data_md, mode=mode,
3546
  hypothesis_contract=hypothesis_contract,
3547
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
3548
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
3549
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
3550
  )
3551
  working_abstract = str(working_front.get("abstract") or "").strip()
3552
  if hypothesis_ledger:
 
3576
  topic, keywords, research_logic_contract, synthesis_result,
3577
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
3578
  hypothesis_contract=hypothesis_contract,
3579
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
3580
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
3581
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
3582
  )
3583
  working_abstract = str(working_front.get("abstract") or "").strip()
3584
  if hypothesis_ledger:
 
3909
  topic, keywords, research_logic_contract, synthesis_result,
3910
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
3911
  hypothesis_contract=hypothesis_contract,
3912
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
3913
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
3914
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
3915
  )
3916
  candidate_abstract = str(working_front.get("abstract") or "").strip()
3917
  if hypothesis_ledger:
 
4138
  topic, keywords, research_logic_contract, synthesis_result,
4139
  data_evidence=planning_data_md, mode=mode,
4140
  hypothesis_contract=hypothesis_contract,
4141
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4142
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4143
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4144
  )
4145
  working_abstract = str(working_front.get("abstract") or "").strip()
4146
  if hypothesis_ledger:
 
4165
  topic, keywords, research_logic_contract, synthesis_result,
4166
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4167
  hypothesis_contract=hypothesis_contract,
4168
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4169
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4170
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4171
  )
4172
  working_abstract = str(working_front.get("abstract") or "").strip()
4173
  if hypothesis_ledger:
 
5309
  final_front = agent.generate_final_front_matter(
5310
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
5311
  hypothesis_contract=hypothesis_contract,
5312
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
5313
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
5314
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
5315
  )
5316
  final_abstract = str(final_front.get("abstract") or "").strip()
5317
  if hypothesis_ledger:
 
5341
  final_front = agent.generate_final_front_matter(
5342
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
5343
  validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
5344
+ authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
5345
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
5346
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
5347
  )
5348
  final_abstract = str(final_front.get("abstract") or "").strip()
5349
  if hypothesis_ledger:
 
5391
  )
5392
  if candidate_title:
5393
  final_title = candidate_title
5394
+ if candidate_keywords:
5395
+ # R7-R2C-R6B: the resolved policy owns keyword identity/count
5396
+ # semantics. No local numeric cardinality branch is permitted.
5397
  final_keywords = candidate_keywords
5398
  else:
5399
  logging.warning(
5400
+ "Adaptive final keyword reconciliation returned no usable keywords; preserving validated Final Front Matter keywords."
 
5401
  )
5402
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
5403
  full_draft_en = self._replace_title(full_draft_en, final_title)
core/pipeline_agent.py CHANGED
@@ -28,7 +28,14 @@ except ImportError:
28
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
31
- from core.study_design_contract import inspect_study_design_scope
 
 
 
 
 
 
 
32
  from core.model_gateway import ProviderNeutralModelGateway
33
  from core.model_usage import ModelUsageEvent, emit_usage_event, normalize_deepseek_usage, prompt_identity, sanitize_metadata
34
 
@@ -1612,22 +1619,27 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1612
  return _stream_direct_research_logic()
1613
 
1614
  @staticmethod
1615
- def _front_matter_keywords(value, minimum=4, maximum=6):
1616
- if isinstance(value, list):
1617
- raw = value
1618
- else:
1619
- raw = re.split(r'[,;\n]+', str(value or ''))
1620
- cleaned = []
1621
- for item in raw:
1622
- term = re.sub(r'^[\s\-•]+|[\s.;]+$', '', str(item or '')).strip()
1623
- if term and term.casefold() not in {existing.casefold() for existing in cleaned}:
1624
- cleaned.append(term)
1625
- if not minimum <= len(cleaned) <= maximum:
1626
- raise DeepSeekRequestError(
1627
- f'Front-matter keywords must contain {minimum}-{maximum} distinct terms; got {len(cleaned)}.'
 
 
 
 
1628
  )
1629
  return cleaned
1630
 
 
1631
  @staticmethod
1632
  def _front_matter_has_citation(value):
1633
  text = str(value or '')
@@ -1696,11 +1708,39 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1696
  phase,
1697
  max_tokens,
1698
  validator,
 
 
1699
  ):
1700
  """Generate one front-matter object with one bounded content-only retry."""
1701
  base_prompt = str(user_prompt)
1702
  current_prompt = base_prompt
1703
  last_error = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1704
  for local_attempt in range(2):
1705
  # API/auth/balance/cancellation/transport behavior remains owned by
1706
  # call_deepseek(). Those failures are deliberately not caught here.
@@ -1750,14 +1790,14 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1750
  'supported/rejected hypotheses, statistical significance, coefficients, p-values, fit '
1751
  'statistics, substantive findings, or conclusions. Use prospective/non-result language such '
1752
  'as examines, investigates, asks, develops, evaluates, or is designed to assess. Keep exactly '
1753
- 'a non-empty string field "abstract" and a JSON array field "keywords" containing 4-6 distinct '
1754
- 'non-empty academic terms. Do not describe the correction and do not wrap the JSON in Markdown.'
1755
  )
1756
  else:
1757
  current_prompt = base_prompt + (
1758
  '\n\nLOCAL VALIDATION RETRY: The previous response failed local JSON/schema/content validation. '
1759
  'Return a fresh complete JSON object only, with exactly a non-empty string field "abstract" '
1760
- 'and a JSON array field "keywords" containing 4-6 distinct non-empty academic terms. '
1761
  'Do not describe the correction and do not wrap the JSON in Markdown.'
1762
  )
1763
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
@@ -1788,28 +1828,28 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1788
 
1789
  @staticmethod
1790
  def _quantitative_front_matter_fallback(contract):
1791
- """Last-resort degraded front matter derived only from frozen authority.
1792
-
1793
- This fallback is intentionally plain. It is used only after bounded
1794
- semantic reconciliation cannot produce a validated result; it never
1795
- replaces the normal LLM path.
1796
- """
1797
- items = SSciPipelineAgent._quantitative_front_matter_items(contract)
1798
  values = [row.get("name") for row in items if row.get("name")]
1799
- if not values:
1800
- return {"title": "Quantitative Management Study", "keywords": ["quantitative research", "management research", "empirical study", "evidence"]}
1801
- title = ", ".join(values[:-1]) + ((", and " if len(values) > 2 else " and ") + values[-1] if len(values) > 1 else "")
1802
- keywords = list(values)
1803
- for item in ("quantitative research", "empirical study", "management research"):
1804
- if len(keywords) >= 4:
1805
- break
1806
- if item.casefold() not in {value.casefold() for value in keywords}:
1807
- keywords.append(item)
1808
- return {"title": title, "keywords": keywords[:6]}
 
1809
 
1810
  @staticmethod
1811
  def _validate_quantitative_front_matter_candidate(candidate, contract):
1812
- """Validate structure and frozen-construct coverage, not prose style."""
1813
  if not isinstance(candidate, dict):
1814
  return ["candidate_not_object"]
1815
  title = re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
@@ -1818,13 +1858,10 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1818
  return ["title_missing"]
1819
  if not isinstance(raw_keywords, list):
1820
  return ["keywords_not_array"]
1821
- keywords = [re.sub(r"\s+", " ", str(item or "")).strip() for item in raw_keywords]
1822
- keywords = [item for item in keywords if item]
1823
  issues = []
1824
- if not 4 <= len(keywords) <= 6:
1825
- issues.append("keywords_count_must_be_4_to_6")
1826
- if len({item.casefold() for item in keywords}) != len(keywords):
1827
- issues.append("keywords_must_be_distinct")
1828
  frozen_items = SSciPipelineAgent._quantitative_front_matter_items(contract)
1829
  combined = (title + " ; " + " ; ".join(keywords)).casefold()
1830
  missing = [
@@ -1837,6 +1874,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1837
  issues.append("missing_frozen_constructs:" + " | ".join(missing))
1838
  return issues
1839
 
 
1840
  def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
1841
  """Preserve R5 user intent while retaining legacy contract reconciliation.
1842
 
@@ -1848,17 +1886,17 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1848
  """
1849
  record = dict(contract or {})
1850
  intent = dict(record.get("user_research_intent") or {})
1851
- if intent:
1852
- source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
1853
- source_keywords = list(intent.get("source_keywords") or record.get("source_keywords") or [])
1854
- if not source_keywords:
1855
- source_keywords = [item.strip() for item in re.split(r"[,;\n|]+", str(keywords or "")) if item.strip()]
1856
  return {
1857
  "title": source_topic,
1858
- "keywords": [re.sub(r"\s+", " ", str(item or "")).strip() for item in source_keywords if str(item or "").strip()],
1859
  }
1860
 
1861
- # Legacy compatibility: no R5 user-intent authority was frozen.
 
1862
  """Adaptively reconcile final AMJ title/keywords to frozen evidence.
1863
 
1864
  Original user topic/keyword seeds are retained as research context. The
@@ -1867,16 +1905,17 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1867
  validates semantic fidelity. After bounded retries, deterministic
1868
  degraded-mode fallback keeps packaging recoverable.
1869
  """
1870
- contract = dict(contract or {})
1871
- frozen = self._quantitative_front_matter_items(contract)
1872
  if not frozen:
1873
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1874
- return self._quantitative_front_matter_fallback(contract)
 
 
 
1875
 
1876
- source_topic = str(contract.get("source_topic") or topic or "").strip()
1877
- source_keywords = list(contract.get("source_keywords") or [])
1878
  if not source_keywords:
1879
- source_keywords = [item.strip() for item in re.split(r"[,;\n|]+", str(keywords or "")) if item.strip()]
1880
  body_context = self._abstract_manuscript_context(str(manuscript or ""), 8000) if manuscript else ""
1881
  feedback = ""
1882
  last_candidate = None
@@ -1891,7 +1930,7 @@ SEMANTIC RULES:
1891
  3. Do NOT present any concept outside the frozen construct-role list as a focal measured, modeled, mediating, moderating, or outcome variable.
1892
  4. Contextual terms may appear when they describe setting/domain rather than an unmeasured focal construct.
1893
  5. Produce a natural journal-style title, not a mechanical template.
1894
- 6. Return 4-6 distinct academic keywords. Include the frozen constructs and add only genuinely useful context/method terms.
1895
  7. Do not mention this reconciliation process.
1896
 
1897
  <ORIGINAL_USER_TOPIC>
@@ -1960,9 +1999,10 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
1960
  extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
1961
  missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
1962
  if not invalid and not extra and not missing:
 
1963
  return {
1964
- "title": re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip(),
1965
- "keywords": [re.sub(r"\s+", " ", str(item)).strip() for item in candidate.get("keywords") or [] if str(item).strip()],
1966
  }
1967
  feedback = "Semantic critic rejected candidate. " + str((critic or {}).get("reason") or "")
1968
  if extra:
@@ -1971,13 +2011,15 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
1971
  feedback += " Missing frozen constructs: " + ", ".join(missing) + "."
1972
  logging.warning("Adaptive front-matter semantic retry | attempt=%s/2 | %s", attempt + 1, feedback[:900])
1973
 
1974
- fallback = self._quantitative_front_matter_fallback(contract)
 
 
1975
  logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
1976
  return fallback
1977
 
1978
 
1979
  def generate_working_front_matter(
1980
- self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
1981
  ):
1982
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1983
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
@@ -2007,15 +2049,16 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2007
  </LOCAL_VALIDATION_FEEDBACK>"""
2008
  def validate_working(parsed):
2009
  abstract = self._validate_working_abstract(parsed.get('abstract'))
2010
- working_keywords = self._front_matter_keywords(parsed.get('keywords'))
2011
  return {'abstract': abstract, 'keywords': working_keywords}
2012
 
2013
  return self._generate_structured_front_matter(
2014
  'You are a senior management-journal editor creating provisional front matter without anticipating results.',
2015
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
 
2016
  )
2017
 
2018
- def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''):
2019
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
2020
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
2021
  body = self._abstract_manuscript_context(manuscript, 18000)
@@ -2045,12 +2088,13 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2045
  meta_findings = self.inspect_authorial_ai_meta(abstract)
2046
  if meta_findings:
2047
  raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
2048
- final_keywords = self._front_matter_keywords(parsed.get('keywords'))
2049
  return {'abstract': abstract, 'keywords': final_keywords}
2050
 
2051
  structured = self._generate_structured_front_matter(
2052
  'You are a senior journal editor finalizing front matter from frozen manuscript evidence.',
2053
  prompt, phase='Final', max_tokens=1200, validator=validate_final,
 
2054
  )
2055
  abstract = structured['abstract']
2056
  if self._front_matter_has_citation(abstract):
 
28
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
31
+ from core.study_design_contract import (
32
+ inspect_study_design_scope,
33
+ resolve_keyword_policy,
34
+ keyword_policy_from_contract,
35
+ apply_keyword_policy,
36
+ evaluate_keyword_count,
37
+ parse_keyword_terms,
38
+ )
39
  from core.model_gateway import ProviderNeutralModelGateway
40
  from core.model_usage import ModelUsageEvent, emit_usage_event, normalize_deepseek_usage, prompt_identity, sanitize_metadata
41
 
 
1619
  return _stream_direct_research_logic()
1620
 
1621
  @staticmethod
1622
+ def _front_matter_keywords(value, authoritative=None, keyword_policy=None, fallback=None):
1623
+ """Normalize keywords through one resolved policy; count is never a generation kill switch."""
1624
+ policy = dict(keyword_policy or resolve_keyword_policy(authoritative))
1625
+ cleaned = apply_keyword_policy(
1626
+ value,
1627
+ policy,
1628
+ authoritative=authoritative,
1629
+ fallback=fallback,
1630
+ )
1631
+ if not cleaned:
1632
+ raise DeepSeekRequestError('Front-matter keywords are empty and no deterministic fallback is available.')
1633
+ status = evaluate_keyword_count(cleaned, policy)
1634
+ if status.get('count_mode') != 'UNSPECIFIED' and not status.get('within_policy_range'):
1635
+ logging.warning(
1636
+ 'Front-matter keyword count advisory | count=%s mode=%s minimum=%s maximum=%s failure_mode=%s nonfatal=true',
1637
+ status.get('count'), status.get('count_mode'), status.get('minimum'), status.get('maximum'),
1638
+ status.get('failure_mode'),
1639
  )
1640
  return cleaned
1641
 
1642
+
1643
  @staticmethod
1644
  def _front_matter_has_citation(value):
1645
  text = str(value or '')
 
1708
  phase,
1709
  max_tokens,
1710
  validator,
1711
+ authoritative_keywords=None,
1712
+ keyword_policy=None,
1713
  ):
1714
  """Generate one front-matter object with one bounded content-only retry."""
1715
  base_prompt = str(user_prompt)
1716
  current_prompt = base_prompt
1717
  last_error = None
1718
+ policy = dict(keyword_policy or resolve_keyword_policy(authoritative_keywords))
1719
+ authority_terms = apply_keyword_policy(
1720
+ authoritative_keywords,
1721
+ policy,
1722
+ authoritative=authoritative_keywords,
1723
+ ) if authoritative_keywords else []
1724
+ if authority_terms:
1725
+ keyword_retry_rule = (
1726
+ 'a JSON array field "keywords" that preserves every user-supplied keyword identity and order. '
1727
+ 'Do not add, delete, merge, truncate, resize, or reinterpret the authoritative list.'
1728
+ )
1729
+ else:
1730
+ count_mode = str(policy.get('count_mode') or 'UNSPECIFIED').upper()
1731
+ minimum = policy.get('preferred_min') if count_mode == 'PREFERRED' else policy.get('required_min')
1732
+ maximum = policy.get('preferred_max') if count_mode == 'PREFERRED' else policy.get('required_max')
1733
+ if count_mode in {'PREFERRED', 'REQUIRED'} and (minimum is not None or maximum is not None):
1734
+ keyword_retry_rule = (
1735
+ 'a JSON array field "keywords" containing distinct non-empty academic terms aligned with the frozen study design. '
1736
+ f'The resolved keyword policy is {count_mode} with minimum={minimum} and maximum={maximum}; '
1737
+ 'treat count as advisory/export-compliance metadata rather than a manuscript-generation failure.'
1738
+ )
1739
+ else:
1740
+ keyword_retry_rule = (
1741
+ 'a JSON array field "keywords" containing a concise, distinct set of non-empty academic terms '
1742
+ 'aligned with the frozen study design. No numeric keyword count is implied by the journal-style label.'
1743
+ )
1744
  for local_attempt in range(2):
1745
  # API/auth/balance/cancellation/transport behavior remains owned by
1746
  # call_deepseek(). Those failures are deliberately not caught here.
 
1790
  'supported/rejected hypotheses, statistical significance, coefficients, p-values, fit '
1791
  'statistics, substantive findings, or conclusions. Use prospective/non-result language such '
1792
  'as examines, investigates, asks, develops, evaluates, or is designed to assess. Keep exactly '
1793
+ 'a non-empty string field "abstract" and ' + keyword_retry_rule + ' '
1794
+ 'Do not describe the correction and do not wrap the JSON in Markdown.'
1795
  )
1796
  else:
1797
  current_prompt = base_prompt + (
1798
  '\n\nLOCAL VALIDATION RETRY: The previous response failed local JSON/schema/content validation. '
1799
  'Return a fresh complete JSON object only, with exactly a non-empty string field "abstract" '
1800
+ 'and ' + keyword_retry_rule + ' '
1801
  'Do not describe the correction and do not wrap the JSON in Markdown.'
1802
  )
1803
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
 
1828
 
1829
  @staticmethod
1830
  def _quantitative_front_matter_fallback(contract):
1831
+ """Deterministic keyword fallback from frozen study-design authority, with no count target."""
1832
+ record = dict(contract or {})
1833
+ policy = keyword_policy_from_contract(record)
1834
+ intent = dict(record.get('user_research_intent') or {})
1835
+ authority = list(intent.get('source_keywords') or [])
1836
+ items = SSciPipelineAgent._quantitative_front_matter_items(record)
 
1837
  values = [row.get("name") for row in items if row.get("name")]
1838
+ if authority:
1839
+ keywords = apply_keyword_policy(authority, policy, authoritative=authority)
1840
+ else:
1841
+ keywords = apply_keyword_policy(values, policy)
1842
+ if not keywords:
1843
+ keywords = ["quantitative management research"]
1844
+ if values:
1845
+ title = ", ".join(values[:-1]) + ((", and " if len(values) > 2 else " and ") + values[-1] if len(values) > 1 else "")
1846
+ else:
1847
+ title = str(intent.get('source_topic') or record.get('source_topic') or 'Quantitative Management Study').strip()
1848
+ return {"title": title, "keywords": keywords}
1849
 
1850
  @staticmethod
1851
  def _validate_quantitative_front_matter_candidate(candidate, contract):
1852
+ """Validate structure and frozen-construct coverage; keyword count is policy metadata, not a fatal schema."""
1853
  if not isinstance(candidate, dict):
1854
  return ["candidate_not_object"]
1855
  title = re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
 
1858
  return ["title_missing"]
1859
  if not isinstance(raw_keywords, list):
1860
  return ["keywords_not_array"]
1861
+ keywords = parse_keyword_terms(raw_keywords, explicit_user=False)
 
1862
  issues = []
1863
+ if not keywords:
1864
+ issues.append("keywords_empty")
 
 
1865
  frozen_items = SSciPipelineAgent._quantitative_front_matter_items(contract)
1866
  combined = (title + " ; " + " ; ".join(keywords)).casefold()
1867
  missing = [
 
1874
  issues.append("missing_frozen_constructs:" + " | ".join(missing))
1875
  return issues
1876
 
1877
+
1878
  def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
1879
  """Preserve R5 user intent while retaining legacy contract reconciliation.
1880
 
 
1886
  """
1887
  record = dict(contract or {})
1888
  intent = dict(record.get("user_research_intent") or {})
1889
+ policy = keyword_policy_from_contract(record, fallback_keywords=keywords)
1890
+ source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
1891
+ source_keywords = list(intent.get("source_keywords") or [])
1892
+ if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and source_keywords:
 
1893
  return {
1894
  "title": source_topic,
1895
+ "keywords": apply_keyword_policy(source_keywords, policy, authoritative=source_keywords),
1896
  }
1897
 
1898
+ # No explicit user keyword authority: retain user-authoritative title when
1899
+ # available, while resolving generated keywords from frozen empirical scope.
1900
  """Adaptively reconcile final AMJ title/keywords to frozen evidence.
1901
 
1902
  Original user topic/keyword seeds are retained as research context. The
 
1905
  validates semantic fidelity. After bounded retries, deterministic
1906
  degraded-mode fallback keeps packaging recoverable.
1907
  """
1908
+ frozen = self._quantitative_front_matter_items(record)
 
1909
  if not frozen:
1910
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1911
+ fallback = self._quantitative_front_matter_fallback(record)
1912
+ if intent and source_topic:
1913
+ fallback["title"] = source_topic
1914
+ return fallback
1915
 
1916
+ source_keywords = list(record.get("source_keywords") or [])
 
1917
  if not source_keywords:
1918
+ source_keywords = parse_keyword_terms(keywords, explicit_user=True)
1919
  body_context = self._abstract_manuscript_context(str(manuscript or ""), 8000) if manuscript else ""
1920
  feedback = ""
1921
  last_candidate = None
 
1930
  3. Do NOT present any concept outside the frozen construct-role list as a focal measured, modeled, mediating, moderating, or outcome variable.
1931
  4. Contextual terms may appear when they describe setting/domain rather than an unmeasured focal construct.
1932
  5. Produce a natural journal-style title, not a mechanical template.
1933
+ 6. Return a concise distinct keyword set aligned with the frozen constructs and genuinely useful context/method terms. Do not infer a numeric keyword count from the journal-style label; count guidance comes only from the resolved keyword policy.
1934
  7. Do not mention this reconciliation process.
1935
 
1936
  <ORIGINAL_USER_TOPIC>
 
1999
  extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
2000
  missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
2001
  if not invalid and not extra and not missing:
2002
+ resolved_title = source_topic if intent else re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
2003
  return {
2004
+ "title": resolved_title,
2005
+ "keywords": apply_keyword_policy(candidate.get("keywords") or [], policy),
2006
  }
2007
  feedback = "Semantic critic rejected candidate. " + str((critic or {}).get("reason") or "")
2008
  if extra:
 
2011
  feedback += " Missing frozen constructs: " + ", ".join(missing) + "."
2012
  logging.warning("Adaptive front-matter semantic retry | attempt=%s/2 | %s", attempt + 1, feedback[:900])
2013
 
2014
+ fallback = self._quantitative_front_matter_fallback(record)
2015
+ if intent and source_topic:
2016
+ fallback["title"] = source_topic
2017
  logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
2018
  return fallback
2019
 
2020
 
2021
  def generate_working_front_matter(
2022
+ self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract='', authoritative_keywords=None, keyword_policy=None, fallback_keywords=None
2023
  ):
2024
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
2025
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
 
2049
  </LOCAL_VALIDATION_FEEDBACK>"""
2050
  def validate_working(parsed):
2051
  abstract = self._validate_working_abstract(parsed.get('abstract'))
2052
+ working_keywords = self._front_matter_keywords(parsed.get('keywords'), authoritative=authoritative_keywords, keyword_policy=keyword_policy, fallback=fallback_keywords)
2053
  return {'abstract': abstract, 'keywords': working_keywords}
2054
 
2055
  return self._generate_structured_front_matter(
2056
  'You are a senior management-journal editor creating provisional front matter without anticipating results.',
2057
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
2058
+ authoritative_keywords=authoritative_keywords, keyword_policy=keyword_policy,
2059
  )
2060
 
2061
+ def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract='', authoritative_keywords=None, keyword_policy=None, fallback_keywords=None):
2062
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
2063
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
2064
  body = self._abstract_manuscript_context(manuscript, 18000)
 
2088
  meta_findings = self.inspect_authorial_ai_meta(abstract)
2089
  if meta_findings:
2090
  raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
2091
+ final_keywords = self._front_matter_keywords(parsed.get('keywords'), authoritative=authoritative_keywords, keyword_policy=keyword_policy, fallback=fallback_keywords)
2092
  return {'abstract': abstract, 'keywords': final_keywords}
2093
 
2094
  structured = self._generate_structured_front_matter(
2095
  'You are a senior journal editor finalizing front matter from frozen manuscript evidence.',
2096
  prompt, phase='Final', max_tokens=1200, validator=validate_final,
2097
+ authoritative_keywords=authoritative_keywords, keyword_policy=keyword_policy,
2098
  )
2099
  abstract = structured['abstract']
2100
  if self._front_matter_has_citation(abstract):
core/study_design_contract.py CHANGED
@@ -27,11 +27,171 @@ def _clean(value: Any, maximum: int = 240) -> str:
27
  return text[:maximum]
28
 
29
 
30
- def _keywords(value: Any) -> list[str]:
31
- raw = value if isinstance(value, (list, tuple)) else re.split(r"[,;\n|]+", str(value or ""))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  out: list[str] = []
33
  seen: set[str] = set()
34
  for item in raw:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  text = _clean(item, 100)
36
  key = text.casefold()
37
  if text and key not in seen:
@@ -40,22 +200,12 @@ def _keywords(value: Any) -> list[str]:
40
  return out[:16]
41
 
42
 
43
-
44
  USER_RESEARCH_INTENT_VERSION = "smartpaper-user-research-intent-v1"
45
 
46
 
47
  def _verbatim_keywords(value: Any) -> list[str]:
48
- """Preserve user keyword identity/order while trimming separator whitespace only."""
49
- raw = value if isinstance(value, (list, tuple)) else re.split(r"[,;\n|]+", str(value or ""))
50
- out: list[str] = []
51
- seen: set[str] = set()
52
- for item in raw:
53
- text = re.sub(r"\s+", " ", str(item or "")).strip()
54
- key = text.casefold()
55
- if text and key not in seen:
56
- out.append(text)
57
- seen.add(key)
58
- return out[:24]
59
 
60
 
61
  _MODERATION_CUES = (
@@ -105,7 +255,9 @@ def _contains_any(text: str, patterns: tuple[str, ...]) -> bool:
105
  def build_user_research_intent(topic: str, keywords: Any, *, mode: str = "") -> dict[str, Any]:
106
  """Freeze user topic/keywords as research-intent authority, separate from literature evidence."""
107
  source_topic = re.sub(r"\s+", " ", str(topic or "")).strip()
108
- source_keywords = _verbatim_keywords(keywords)
 
 
109
  text = " ; ".join([source_topic] + source_keywords)
110
  relation_types: list[str] = []
111
  if _contains_any(text, _MODERATION_CUES):
@@ -132,6 +284,8 @@ def build_user_research_intent(topic: str, keywords: Any, *, mode: str = "") ->
132
  "mode": str(mode or "").strip().casefold(),
133
  "source_topic": source_topic,
134
  "source_keywords": source_keywords,
 
 
135
  "explicit_relation_types": relation_types,
136
  "qualitative_design": qualitative_design,
137
  "qualitative_data_source_families": source_families,
@@ -551,6 +705,7 @@ def build_quantitative_contract(
551
  "source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
552
  "source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
553
  "user_research_intent": user_intent,
 
554
  "research_problem": _clean(raw.get("research_problem"), 500),
555
  "research_questions": _keywords(raw.get("research_questions") or []),
556
  "design_family": "cross_sectional_observational_survey",
@@ -727,6 +882,7 @@ def build_qualitative_contract(topic: str, keywords: Any, metadata: dict[str, An
727
  "source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
728
  "source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
729
  "user_research_intent": user_intent,
 
730
  "research_problem": "",
731
  "research_questions": [],
732
  "design_family": design or "Iterative thematic/process analysis",
 
27
  return text[:maximum]
28
 
29
 
30
+ KEYWORD_POLICY_VERSION = "smartpaper-keyword-policy-v1"
31
+ KEYWORD_SEPARATOR_POLICY = "SEMICOLON_NEWLINE_PIPE_PRIMARY_COMMA_FALLBACK"
32
+ _KEYWORD_COUNT_MODES = {"UNSPECIFIED", "PREFERRED", "REQUIRED"}
33
+ _KEYWORD_AUTHORITY_SOURCES = {"USER_EXPLICIT", "SYSTEM_GENERATED", "LEGACY"}
34
+
35
+
36
+ def parse_keyword_terms(value: Any, *, explicit_user: bool = False, diagnostics: list[dict[str, Any]] | None = None) -> list[str]:
37
+ """Parse keyword identities without imposing an academic count limit.
38
+
39
+ Explicit user text uses semicolon/newline/pipe as primary separators so an
40
+ internal comma can remain part of a deliberate keyword phrase. Comma is
41
+ used only as a compatibility fallback when no primary separator is present.
42
+ List/tuple input is already structurally separated and is never split again.
43
+ """
44
+ if isinstance(value, (list, tuple)):
45
+ raw = list(value)
46
+ else:
47
+ text = str(value or "")
48
+ if explicit_user and re.search(r"[;\n|]", text):
49
+ raw = re.split(r"[;\n|]+", text)
50
+ else:
51
+ raw = re.split(r"[,;\n|]+", text)
52
  out: list[str] = []
53
  seen: set[str] = set()
54
  for item in raw:
55
+ term = re.sub(r"\s+", " ", str(item or "")).strip()
56
+ key = term.casefold()
57
+ if not term:
58
+ continue
59
+ if key in seen:
60
+ if diagnostics is not None:
61
+ diagnostics.append({"type": "keyword_duplicate_deduped", "keyword": term})
62
+ continue
63
+ out.append(term)
64
+ seen.add(key)
65
+ return out
66
+
67
+
68
+ def resolve_keyword_policy(
69
+ user_keywords: Any = None,
70
+ *,
71
+ authority_source: str = "",
72
+ configured_policy: dict[str, Any] | None = None,
73
+ ) -> dict[str, Any]:
74
+ """Resolve one versioned keyword policy without journal-count hardcoding.
75
+
76
+ Numeric preferences/requirements are accepted only when supplied by an
77
+ external/versioned policy record. AMJ/ASQ style labels do not imply a count.
78
+ Generation never fails merely because a count is outside a preference.
79
+ """
80
+ user_terms = parse_keyword_terms(user_keywords, explicit_user=True)
81
+ source = str(authority_source or "").strip().upper()
82
+ if source not in _KEYWORD_AUTHORITY_SOURCES:
83
+ source = "USER_EXPLICIT" if user_terms else "SYSTEM_GENERATED"
84
+
85
+ configured = dict(configured_policy or {})
86
+ count_mode = str(configured.get("count_mode") or "UNSPECIFIED").strip().upper()
87
+ if count_mode not in _KEYWORD_COUNT_MODES:
88
+ count_mode = "UNSPECIFIED"
89
+
90
+ def _optional_nonnegative_int(name: str):
91
+ value = configured.get(name)
92
+ if value in (None, ""):
93
+ return None
94
+ try:
95
+ value = int(value)
96
+ except (TypeError, ValueError):
97
+ return None
98
+ return value if value >= 0 else None
99
+
100
+ preferred_min = _optional_nonnegative_int("preferred_min")
101
+ preferred_max = _optional_nonnegative_int("preferred_max")
102
+ required_min = _optional_nonnegative_int("required_min")
103
+ required_max = _optional_nonnegative_int("required_max")
104
+ if count_mode == "UNSPECIFIED":
105
+ preferred_min = preferred_max = required_min = required_max = None
106
+ elif count_mode == "PREFERRED":
107
+ required_min = required_max = None
108
+ elif count_mode == "REQUIRED":
109
+ preferred_min = preferred_max = None
110
+
111
+ return {
112
+ "version": KEYWORD_POLICY_VERSION,
113
+ "authority_source": source,
114
+ "identity_policy": "PRESERVE_USER_IDENTITIES" if source == "USER_EXPLICIT" else "NORMALIZE_DISTINCT_IDENTITIES",
115
+ "separator_policy": KEYWORD_SEPARATOR_POLICY,
116
+ "duplicate_policy": "DEDUPE_EXACT_WITH_DIAGNOSTIC",
117
+ "journal_requirement_source": str(configured.get("journal_requirement_source") or "").strip(),
118
+ "count_mode": count_mode,
119
+ "preferred_min": preferred_min,
120
+ "preferred_max": preferred_max,
121
+ "required_min": required_min,
122
+ "required_max": required_max,
123
+ "count_failure_mode": "EXPORT_COMPLIANCE" if count_mode == "REQUIRED" else "ADVISORY",
124
+ "fallback_source": "FROZEN_STUDY_DESIGN_CONTRACT",
125
+ "target_language_policy": "TRANSLATE_IDENTITY_SET_WITHOUT_RESIZE",
126
+ }
127
+
128
+
129
+ def keyword_policy_from_contract(contract: dict[str, Any] | None, *, fallback_keywords: Any = None) -> dict[str, Any]:
130
+ record = dict(contract or {})
131
+ intent = dict(record.get("user_research_intent") or {})
132
+ user_terms = list(intent.get("source_keywords") or [])
133
+ configured = dict(record.get("keyword_policy") or intent.get("keyword_policy") or {})
134
+ authority_source = str(configured.get("authority_source") or "").strip()
135
+ if not user_terms and not authority_source:
136
+ # Legacy serialized contracts may have source_keywords but no explicit
137
+ # R5/R6 user-authority marker; do not silently promote them to user input.
138
+ return resolve_keyword_policy(None, authority_source="LEGACY", configured_policy=configured)
139
+ return resolve_keyword_policy(
140
+ user_terms if user_terms else fallback_keywords,
141
+ authority_source=authority_source,
142
+ configured_policy=configured,
143
+ )
144
+
145
+
146
+ def evaluate_keyword_count(keywords: Any, policy: dict[str, Any] | None) -> dict[str, Any]:
147
+ """Return advisory/compliance status; never decides manuscript completion."""
148
+ terms = parse_keyword_terms(keywords, explicit_user=False)
149
+ resolved = dict(policy or resolve_keyword_policy())
150
+ mode = str(resolved.get("count_mode") or "UNSPECIFIED").upper()
151
+ count = len(terms)
152
+ minimum = resolved.get("preferred_min") if mode == "PREFERRED" else resolved.get("required_min")
153
+ maximum = resolved.get("preferred_max") if mode == "PREFERRED" else resolved.get("required_max")
154
+ within = True
155
+ if minimum is not None and count < int(minimum):
156
+ within = False
157
+ if maximum is not None and count > int(maximum):
158
+ within = False
159
+ return {
160
+ "count": count,
161
+ "count_mode": mode,
162
+ "within_policy_range": within,
163
+ "minimum": minimum,
164
+ "maximum": maximum,
165
+ "blocking_generation": False,
166
+ "failure_mode": str(resolved.get("count_failure_mode") or "ADVISORY"),
167
+ }
168
+
169
+
170
+ def apply_keyword_policy(
171
+ value: Any,
172
+ policy: dict[str, Any] | None,
173
+ *,
174
+ authoritative: Any = None,
175
+ fallback: Any = None,
176
+ ) -> list[str]:
177
+ resolved = dict(policy or resolve_keyword_policy(authoritative))
178
+ source = str(resolved.get("authority_source") or "SYSTEM_GENERATED").upper()
179
+ if source == "USER_EXPLICIT":
180
+ terms = parse_keyword_terms(authoritative, explicit_user=True)
181
+ if terms:
182
+ return terms
183
+ terms = parse_keyword_terms(value, explicit_user=False)
184
+ if not terms and fallback is not None:
185
+ terms = parse_keyword_terms(fallback, explicit_user=False)
186
+ return terms
187
+
188
+
189
+ def _keywords(value: Any) -> list[str]:
190
+ # Internal non-user lists retain bounded metadata normalization. This is not
191
+ # a reader-facing front-matter academic cardinality rule.
192
+ out: list[str] = []
193
+ seen: set[str] = set()
194
+ for item in parse_keyword_terms(value, explicit_user=False):
195
  text = _clean(item, 100)
196
  key = text.casefold()
197
  if text and key not in seen:
 
200
  return out[:16]
201
 
202
 
 
203
  USER_RESEARCH_INTENT_VERSION = "smartpaper-user-research-intent-v1"
204
 
205
 
206
  def _verbatim_keywords(value: Any) -> list[str]:
207
+ """Preserve explicit user keyword identity/order under the resolved separator policy."""
208
+ return parse_keyword_terms(value, explicit_user=True)
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  _MODERATION_CUES = (
 
255
  def build_user_research_intent(topic: str, keywords: Any, *, mode: str = "") -> dict[str, Any]:
256
  """Freeze user topic/keywords as research-intent authority, separate from literature evidence."""
257
  source_topic = re.sub(r"\s+", " ", str(topic or "")).strip()
258
+ keyword_diagnostics: list[dict[str, Any]] = []
259
+ source_keywords = parse_keyword_terms(keywords, explicit_user=True, diagnostics=keyword_diagnostics)
260
+ keyword_policy = resolve_keyword_policy(source_keywords)
261
  text = " ; ".join([source_topic] + source_keywords)
262
  relation_types: list[str] = []
263
  if _contains_any(text, _MODERATION_CUES):
 
284
  "mode": str(mode or "").strip().casefold(),
285
  "source_topic": source_topic,
286
  "source_keywords": source_keywords,
287
+ "keyword_policy": keyword_policy,
288
+ "keyword_diagnostics": keyword_diagnostics,
289
  "explicit_relation_types": relation_types,
290
  "qualitative_design": qualitative_design,
291
  "qualitative_data_source_families": source_families,
 
705
  "source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
706
  "source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
707
  "user_research_intent": user_intent,
708
+ "keyword_policy": dict(user_intent.get("keyword_policy") or resolve_keyword_policy()),
709
  "research_problem": _clean(raw.get("research_problem"), 500),
710
  "research_questions": _keywords(raw.get("research_questions") or []),
711
  "design_family": "cross_sectional_observational_survey",
 
882
  "source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
883
  "source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
884
  "user_research_intent": user_intent,
885
+ "keyword_policy": dict(user_intent.get("keyword_policy") or resolve_keyword_policy()),
886
  "research_problem": "",
887
  "research_questions": [],
888
  "design_family": design or "Iterative thematic/process analysis",