Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4aff01e
[FEAT] Add document_insights mode to LLMWhisperer V2 adapter with sig…
pk-zipstack Apr 7, 2026
b982e58
[FEAT] Compute signature page references for frontend page navigation
pk-zipstack Apr 9, 2026
2cfcede
[MISC] Add DOC_INSIGHTS debug loggers across signature metadata flow
pk-zipstack Apr 15, 2026
0caec15
Merge branch 'main' into feat/llmwhisperer-document-insights-mode
pk-zipstack Apr 15, 2026
cc63bdd
[FIX] Allow empty user_id in indexing-status internal endpoint
pk-zipstack Apr 15, 2026
ea011a4
[FEAT] Surface signature page highlights in Prompt Studio for documen…
pk-zipstack May 14, 2026
e5333bc
[FIX] Pick content line per page and use word-boundary matching for s…
pk-zipstack May 14, 2026
df77c3e
[FIX] Allow signature page jumps without the enable_highlight toggle
pk-zipstack May 14, 2026
d49d924
[MISC] Address pre-commit + SonarCloud findings on signature highlights
pk-zipstack May 14, 2026
a9a9840
Merge branch 'main' into feat/llmwhisperer-document-insights-mode
pk-zipstack May 14, 2026
4cfbc8c
[MISC] Reduce cognitive complexity flagged by SonarCloud
pk-zipstack May 14, 2026
fd3e200
[MISC] Final SonarCloud cleanup: exception logging, complexity, strin…
pk-zipstack May 14, 2026
108ddde
[MISC] Move signature LLM-context formatter into shared SDK helper
pk-zipstack May 14, 2026
91fd22f
[MISC] Extract SSRF webhook-URL helper into shared SDK module
pk-zipstack May 14, 2026
23609a0
Merge branch 'main' into feat/llmwhisperer-document-insights-mode
pk-zipstack Aug 18, 2026
5609ac2
Merge branch 'main' into feat/llmwhisperer-document-insights-mode
pk-zipstack Aug 18, 2026
8dcd77c
Merge branch 'main' into feat/llmwhisperer-document-insights-mode
muhammad-ali-e Sep 3, 2026
628f50f
UN-3372 [FIX] Harden signature-highlight page keys and align the fron…
pk-zipstack Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ class ToolStudioPromptKeys:
EXECUTION_SOURCE = "execution_source"
LINE_ITEM = "line-item"
CUSTOM_DATA = "custom_data"
SIGNATURE_METADATA = "signature_metadata"
SIGNATURE_PAGE_REFERENCES = "signature_page_references"
# Webhook postprocessing settings
ENABLE_POSTPROCESSING_WEBHOOK = "enable_postprocessing_webhook"
POSTPROCESSING_WEBHOOK_URL = "postprocessing_webhook_url"
Expand Down
6 changes: 4 additions & 2 deletions backend/prompt_studio/prompt_studio_core_v2/internal_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,13 @@ def indexing_status(request):
user_id = data.get("user_id", "")
doc_id_key = data.get("doc_id_key", "")

if not action or not org_id or not user_id or not doc_id_key:
# user_id may be empty (e.g. mock auth users) - it's only used as a
# Redis cache key fragment, so empty is acceptable.
if not action or not org_id or not doc_id_key:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return JsonResponse(
{
"success": False,
"error": "action, org_id, user_id, doc_id_key are required",
"error": "action, org_id, doc_id_key are required",
},
status=status.HTTP_400_BAD_REQUEST,
)
Expand Down
154 changes: 140 additions & 14 deletions backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import time
import uuid
from pathlib import Path
from typing import Any
from typing import Any, NamedTuple

from account_v2.constants import Common
from account_v2.models import User
Expand Down Expand Up @@ -88,7 +88,20 @@

CHOICES_JSON = "/static/select_choices.json"

logger = logging.getLogger(__name__)

class ExtractResult(NamedTuple):
"""Return value of ``PromptStudioHelper.dynamic_extractor``.

``signature_metadata`` and ``signature_page_references`` are populated
only when the x2text adapter is LLMWhisperer V2 in ``document_insights``
mode and the document contains signatures. They are read either from
the live extract dispatch result (cache miss) or from the on-disk
``.doc_insights.json`` sidecar (cache hit).
"""

text: str
signature_metadata: dict[str, Any] | None = None
signature_page_references: dict[str, Any] | None = None


def _adapter_accessible_by(adapter: AdapterInstance, user: User) -> bool:
Expand Down Expand Up @@ -837,14 +850,15 @@ def build_fetch_response_payload(
)

# Extract (blocking, usually cached)
extracted_text = PromptStudioHelper.dynamic_extractor(
extract_result = PromptStudioHelper.dynamic_extractor(
profile_manager=profile_manager,
file_path=file_path,
org_id=org_id,
document_id=document_id,
run_id=run_id,
enable_highlight=tool.enable_highlight,
)
extracted_text = extract_result.text

is_summary = tool.summarize_as_source
if is_summary:
Expand Down Expand Up @@ -942,6 +956,9 @@ def build_fetch_response_payload(
tool_settings[TSPKeys.WORD_CONFIDENCE_POSTAMBLE] = getattr(
settings, TSPKeys.WORD_CONFIDENCE_POSTAMBLE.upper(), ""
)
PromptStudioHelper._inject_signature_data_into_tool_settings(
tool_settings, extract_result
)

file_hash = fs_instance.get_hash_from_file(path=extract_path)

Expand Down Expand Up @@ -1060,14 +1077,15 @@ def build_bulk_fetch_response_payload(
)

# Extract ONCE (blocking, usually cached)
extracted_text = PromptStudioHelper.dynamic_extractor(
extract_result = PromptStudioHelper.dynamic_extractor(
profile_manager=profile_manager,
file_path=file_path,
org_id=org_id,
document_id=document_id,
run_id=run_id,
enable_highlight=tool.enable_highlight,
)
extracted_text = extract_result.text

is_summary = tool.summarize_as_source
if is_summary:
Expand Down Expand Up @@ -1135,6 +1153,9 @@ def build_bulk_fetch_response_payload(
tool_settings[TSPKeys.WORD_CONFIDENCE_POSTAMBLE] = getattr(
settings, TSPKeys.WORD_CONFIDENCE_POSTAMBLE.upper(), ""
)
PromptStudioHelper._inject_signature_data_into_tool_settings(
tool_settings, extract_result
)

file_hash = fs_instance.get_hash_from_file(path=extract_path)

Expand Down Expand Up @@ -1241,7 +1262,7 @@ def build_single_pass_payload(
)

# Extract (blocking, usually cached)
PromptStudioHelper.dynamic_extractor(
extract_result = PromptStudioHelper.dynamic_extractor(
profile_manager=default_profile,
file_path=doc_path,
org_id=org_id,
Expand Down Expand Up @@ -1280,6 +1301,9 @@ def build_single_pass_payload(
or TSPKeys.SIMPLE,
TSPKeys.SIMILARITY_TOP_K: default_profile.similarity_top_k,
}
PromptStudioHelper._inject_signature_data_into_tool_settings(
tool_settings, extract_result
)

lookup_configs = get_lookup_configs_for_tool(tool, prompts=prompts)
if lookup_configs:
Expand Down Expand Up @@ -1490,14 +1514,15 @@ def index_document(
tool=util,
)

extracted_text = PromptStudioHelper.dynamic_extractor(
extract_result = PromptStudioHelper.dynamic_extractor(
profile_manager=default_profile,
file_path=file_path,
org_id=org_id,
document_id=document_id,
run_id=run_id,
enable_highlight=tool.enable_highlight,
)
extracted_text = extract_result.text
if tool.summarize_context:
summarize_file_path = PromptStudioHelper.summarize(
file_name, org_id, run_id, tool
Expand Down Expand Up @@ -1753,7 +1778,7 @@ def _execute_single_prompt(
# Validation responses are user-facing; DRF renders them as-is.
raise
except Exception as e:
logger.error(
logger.exception(
f"[{tool.tool_id}] Error while fetching response for "
f"prompt {id} and doc {document_id}: {e}"
)
Expand Down Expand Up @@ -1823,7 +1848,7 @@ def _execute_prompts_in_single_pass(
# Validation responses are user-facing; DRF renders them as-is.
raise
except Exception as e:
logger.error(
logger.exception(
f"[{tool.tool_id}] Error while fetching single pass response: {e}"
)
PromptStudioHelper._publish_log(
Expand Down Expand Up @@ -2021,14 +2046,15 @@ def _fetch_response(
tool=util,
)
logger.info(f"Extracting text from {file_path} for {doc_id}")
extracted_text = PromptStudioHelper.dynamic_extractor(
extract_result = PromptStudioHelper.dynamic_extractor(
profile_manager=profile_manager,
file_path=file_path,
org_id=org_id,
document_id=document_id,
run_id=run_id,
enable_highlight=tool.enable_highlight,
)
extracted_text = extract_result.text
logger.info(f"Extracted text from {file_path} for {doc_id}")
if is_summary:
profile_manager.chunk_size = 0
Expand Down Expand Up @@ -2139,6 +2165,9 @@ def _fetch_response(
tool_settings[TSPKeys.WORD_CONFIDENCE_POSTAMBLE] = getattr(
settings, TSPKeys.WORD_CONFIDENCE_POSTAMBLE.upper(), ""
)
PromptStudioHelper._inject_signature_data_into_tool_settings(
tool_settings, extract_result
)
file_hash = fs_instance.get_hash_from_file(path=doc_path)

payload = {
Expand Down Expand Up @@ -2349,7 +2378,7 @@ def dynamic_indexer(
msg = e.actual_err.response.json().get("error", str(e))

msg = f"Error while indexing '{filename}'. {msg}"
logger.error(msg, stack_info=True, exc_info=True)
logger.exception(msg, stack_info=True)
PromptStudioHelper._publish_log(
{"tool_id": tool_id, "run_id": run_id, "doc_name": filename},
LogLevels.ERROR,
Expand Down Expand Up @@ -2403,7 +2432,7 @@ def _fetch_single_pass_response(
file_path = os.path.join(
directory, "extract", os.path.splitext(filename)[0] + ".txt"
)
PromptStudioHelper.dynamic_extractor(
extract_result = PromptStudioHelper.dynamic_extractor(
profile_manager=default_profile,
file_path=input_file_path,
org_id=org_id,
Expand Down Expand Up @@ -2441,6 +2470,9 @@ def _fetch_single_pass_response(
default_profile.retrieval_strategy or TSPKeys.SIMPLE
)
tool_settings[TSPKeys.SIMILARITY_TOP_K] = default_profile.similarity_top_k
PromptStudioHelper._inject_signature_data_into_tool_settings(
tool_settings, extract_result
)
for prompt in prompts:
if not prompt.prompt:
raise EmptyPromptError()
Expand Down Expand Up @@ -2500,6 +2532,83 @@ def get_tool_from_tool_id(tool_id: str) -> CustomTool | None:
except CustomTool.DoesNotExist:
return None

@staticmethod
def _log_signature_capture(
signature_metadata: dict[str, Any] | None,
signature_page_references: dict[str, Any] | None,
document_id: str,
) -> None:
"""Log signature data capture from a fresh extract dispatch."""
if not (signature_metadata or signature_page_references):
return
logger.info(
"DOC_INSIGHTS dynamic_extractor: captured signature data "
"(pages=%s, refs=%s) for document %s",
list(signature_metadata.keys()) if signature_metadata else [],
list(signature_page_references.keys()) if signature_page_references else [],
document_id,
)

@staticmethod
def _inject_signature_data_into_tool_settings(
tool_settings: dict[str, Any],
extract_result: "ExtractResult",
) -> None:
"""Inject ``signature_metadata`` / ``signature_page_references``
from the extract result into ``tool_settings`` (mutated in place).

No-op when document_insights mode produced no signature data.
"""
if extract_result.signature_metadata:
tool_settings[TSPKeys.SIGNATURE_METADATA] = extract_result.signature_metadata
if extract_result.signature_page_references:
tool_settings[TSPKeys.SIGNATURE_PAGE_REFERENCES] = (
extract_result.signature_page_references
)

@staticmethod
def _signature_sidecar_path(extract_file_path: str) -> str:
p = Path(extract_file_path)
return str(p.with_suffix("")) + ".doc_insights.json"

@staticmethod
def _load_signature_sidecar(
extract_file_path: str,
fs_instance: Any,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Return ``(signature_metadata, signature_page_references)`` from the
sidecar, or ``(None, None)`` if the sidecar is missing or unreadable.

Signature data is only written by the executor when a document
contains signatures in document_insights mode; cache-hit calls
for documents extracted in other modes legitimately have no
sidecar, so absence is not an error.
"""
sidecar_path = PromptStudioHelper._signature_sidecar_path(extract_file_path)
try:
raw = fs_instance.read(path=sidecar_path, mode="r")
except FileNotFoundError:
return None, None
except Exception as e:
logger.warning(
"DOC_INSIGHTS sidecar: failed to read %s: %s",
sidecar_path,
e,
)
return None, None
try:
data = json.loads(raw)
except (TypeError, ValueError) as e:
logger.warning(
"DOC_INSIGHTS sidecar: failed to parse %s: %s",
sidecar_path,
e,
)
return None, None
sig_meta = data.get("signature_metadata") or None
sig_refs = data.get("signature_page_references") or None
return sig_meta, sig_refs
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@staticmethod
def dynamic_extractor(
file_path: str,
Expand All @@ -2508,7 +2617,7 @@ def dynamic_extractor(
org_id: str,
profile_manager: ProfileManager,
document_id: str,
) -> str:
) -> ExtractResult:
# Guard against None metadata (when adapter_metadata_b is None)
metadata = profile_manager.x2text.metadata or {}
x2text_config_hash = ToolUtils.hash_str(json.dumps(metadata, sort_keys=True))
Expand Down Expand Up @@ -2538,7 +2647,15 @@ def dynamic_extractor(
try:
extracted_text = fs_instance.read(path=extract_file_path, mode="r")
logger.info("Extracted text found. Reading from file..")
return extracted_text
sig_meta, sig_refs = PromptStudioHelper._load_signature_sidecar(
extract_file_path=extract_file_path,
fs_instance=fs_instance,
)
return ExtractResult(
text=extracted_text,
signature_metadata=sig_meta,
signature_page_references=sig_refs,
)
except FileNotFoundError as e:
logger.warning(
f"File not found for extraction. {extract_file_path}. {e}"
Expand Down Expand Up @@ -2592,6 +2709,11 @@ def dynamic_extractor(
)

extracted_text = result.data.get("extracted_text", "")
signature_metadata = result.data.get("signature_metadata")
signature_page_references = result.data.get("signature_page_references")
PromptStudioHelper._log_signature_capture(
signature_metadata, signature_page_references, document_id
)
success = PromptStudioIndexHelper.mark_extraction_status(
document_id=document_id,
profile_manager=profile_manager,
Expand All @@ -2604,7 +2726,11 @@ def dynamic_extractor(
f"Extraction completed but status not saved."
)

return extracted_text
return ExtractResult(
text=extracted_text,
signature_metadata=signature_metadata,
signature_page_references=signature_page_references,
)

@staticmethod
def export_project_settings(tool: CustomTool) -> dict:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,14 @@ def _get_explicit(profile_manager_id: str) -> Any:
patch.object(helper, "validate_adapter_status", autospec=True),
patch.object(helper, "validate_profile_manager_owner_access", autospec=True),
patch.object(helper, "_get_platform_api_key", autospec=True, return_value="pk"),
patch.object(helper, "dynamic_extractor", autospec=True, return_value="text"),
# dynamic_extractor returns an ExtractResult (text + optional
# document_insights signature data), not a bare string.
patch.object(
helper,
"dynamic_extractor",
autospec=True,
return_value=psh.ExtractResult(text="text"),
),
# Must be a dict with a non-pending status: the builders early-return a
# pending response (and no cb_kwargs) when indexing is still running.
patch.object(
Expand Down
Loading
Loading