АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе
This commit is contained in:
parent
4060a5e575
commit
4d59672576
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Agent Guardrails (NDC_1C)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
This repository has two assistant lanes:
|
||||||
|
- `address_query` for fast factual MCP lookups.
|
||||||
|
- deep/canonical normalizer lane for heavy analysis.
|
||||||
|
|
||||||
|
Do not route plain address lookups to deep lane when an address fallback is possible.
|
||||||
|
|
||||||
|
## Encoding Policy (Mandatory)
|
||||||
|
To prevent recurring mojibake (`Р...`, `С...`) issues:
|
||||||
|
|
||||||
|
1. Use UTF-8 for all source and exported text artifacts.
|
||||||
|
2. When reading or writing text in PowerShell, always set encoding explicitly (`-Encoding utf8`).
|
||||||
|
3. For MCP/live payloads, normalize potentially broken UTF-8/CP1251 strings before matching/filtering.
|
||||||
|
4. Never store business-specific names in static normalization dictionaries.
|
||||||
|
- Use runtime anchor resolution from live rows.
|
||||||
|
5. Before finishing a change touching assistant text/debug/export:
|
||||||
|
- run tests that cover conversation export and address runtime,
|
||||||
|
- verify no new mojibake markers in changed files.
|
||||||
|
|
||||||
|
## Address Runtime Rules
|
||||||
|
1. MSP/MCP-first. Snapshots are fallback only.
|
||||||
|
2. `false_factual_rate` must remain `0`.
|
||||||
|
3. For short follow-ups with loose anchor form (`по <anchor>`), keep in address lane.
|
||||||
|
4. If factual result is unavailable, return operational limited reason, never hallucinate.
|
||||||
|
|
@ -0,0 +1,585 @@
|
||||||
|
# Assistant conversation export
|
||||||
|
session_id: asst--iAIydc_EC
|
||||||
|
export_mode: technical
|
||||||
|
exported_at: 2026-04-01T12:33:31.795Z
|
||||||
|
|
||||||
|
## 1. user
|
||||||
|
message_id: msg-2d0MoaXT6W
|
||||||
|
created_at: 2026-04-01T12:32:28.939Z
|
||||||
|
reply_type: n/a
|
||||||
|
|
||||||
|
Какой остаток по счету 60 на дату 2020-07-31?
|
||||||
|
|
||||||
|
## 2. assistant
|
||||||
|
message_id: msg-MVrv4gqMpT
|
||||||
|
created_at: 2026-04-01T12:32:29.818Z
|
||||||
|
reply_type: partial_coverage
|
||||||
|
trace_id: address-Q_QdUVA19F
|
||||||
|
|
||||||
|
В live-данных по текущему фильтру записи не найдены.
|
||||||
|
Причина: по выбранным фильтрам в live-выборке нет строк.
|
||||||
|
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
|
||||||
|
|
||||||
|
### technical_debug_payload_json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trace_id": "address-Q_QdUVA19F",
|
||||||
|
"prompt_version": "address_query_runtime_v1",
|
||||||
|
"schema_version": "address_query_runtime_v1",
|
||||||
|
"fallback_type": "partial",
|
||||||
|
"route_summary": null,
|
||||||
|
"fragments": [],
|
||||||
|
"requirements_extracted": [],
|
||||||
|
"coverage_report": {
|
||||||
|
"requirements_total": 0,
|
||||||
|
"requirements_covered": 0,
|
||||||
|
"requirements_uncovered": [],
|
||||||
|
"requirements_partially_covered": [],
|
||||||
|
"clarification_needed_for": [],
|
||||||
|
"out_of_scope_requirements": []
|
||||||
|
},
|
||||||
|
"routes": [],
|
||||||
|
"retrieval_status": [],
|
||||||
|
"retrieval_results": [],
|
||||||
|
"answer_grounding_check": {
|
||||||
|
"status": "partial",
|
||||||
|
"route_subject_match": true,
|
||||||
|
"missing_requirements": [],
|
||||||
|
"reasons": [
|
||||||
|
"address_action_detected",
|
||||||
|
"address_entity_detected",
|
||||||
|
"aggregate_signal_detected",
|
||||||
|
"account_balance_signal_detected"
|
||||||
|
],
|
||||||
|
"why_included_summary": [],
|
||||||
|
"selection_reason_summary": []
|
||||||
|
},
|
||||||
|
"dropped_intent_segments": [],
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_mode_confidence": "high",
|
||||||
|
"query_shape": "AGGREGATE_LOOKUP",
|
||||||
|
"query_shape_confidence": "high",
|
||||||
|
"detected_intent": "account_balance_snapshot",
|
||||||
|
"detected_intent_confidence": "high",
|
||||||
|
"extracted_filters": {
|
||||||
|
"sort": "period_desc",
|
||||||
|
"limit": 20,
|
||||||
|
"account": "60",
|
||||||
|
"as_of_date": "2020-07-31"
|
||||||
|
},
|
||||||
|
"missing_required_filters": [],
|
||||||
|
"selected_recipe": "address_movements_account_snapshot_v1",
|
||||||
|
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
|
||||||
|
"account_scope_mode": "strict",
|
||||||
|
"account_scope_fallback_applied": false,
|
||||||
|
"anchor_type": "account",
|
||||||
|
"anchor_value_raw": "60",
|
||||||
|
"anchor_value_resolved": "60",
|
||||||
|
"resolver_confidence": "high",
|
||||||
|
"ambiguity_count": 0,
|
||||||
|
"match_failure_stage": "none",
|
||||||
|
"match_failure_reason": null,
|
||||||
|
"mcp_call_status": "raw_rows_received_but_not_materialized",
|
||||||
|
"rows_fetched": 20,
|
||||||
|
"raw_rows_received": 20,
|
||||||
|
"rows_after_account_scope": 0,
|
||||||
|
"rows_after_recipe_filter": 0,
|
||||||
|
"rows_materialized": 0,
|
||||||
|
"rows_matched": 0,
|
||||||
|
"raw_row_keys_sample": [
|
||||||
|
"Период",
|
||||||
|
"Регистратор",
|
||||||
|
"СчетДт",
|
||||||
|
"СчетКт",
|
||||||
|
"Сумма",
|
||||||
|
"Period",
|
||||||
|
"Registrator",
|
||||||
|
"AccountDt",
|
||||||
|
"AccountKt",
|
||||||
|
"Amount"
|
||||||
|
],
|
||||||
|
"materialization_drop_reason": "dropped_by_account_scope_filter",
|
||||||
|
"account_token_raw": "60",
|
||||||
|
"account_token_normalized": "60",
|
||||||
|
"account_scope_fields_checked": [
|
||||||
|
"account_dt",
|
||||||
|
"account_kt",
|
||||||
|
"registrator",
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
|
||||||
|
"account_scope_drop_reason": "no_rows_after_scope_filter",
|
||||||
|
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
"limited_reason_category": "empty_match",
|
||||||
|
"response_type": "LIMITED_WITH_REASON",
|
||||||
|
"answer_structure_v11": null,
|
||||||
|
"investigation_state_snapshot": null,
|
||||||
|
"normalized": null,
|
||||||
|
"normalizer_output": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. user
|
||||||
|
message_id: msg-tPODcsFT2j
|
||||||
|
created_at: 2026-04-01T12:32:43.787Z
|
||||||
|
reply_type: n/a
|
||||||
|
|
||||||
|
Какой остаток по счету 76 на дату 2020-12-31?
|
||||||
|
|
||||||
|
## 4. assistant
|
||||||
|
message_id: msg-sbyXymS8Tu
|
||||||
|
created_at: 2026-04-01T12:32:44.036Z
|
||||||
|
reply_type: partial_coverage
|
||||||
|
trace_id: address-tWfGlq9Bif
|
||||||
|
|
||||||
|
В live-данных по текущему фильтру записи не найдены.
|
||||||
|
Причина: по выбранным фильтрам в live-выборке нет строк.
|
||||||
|
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
|
||||||
|
|
||||||
|
### technical_debug_payload_json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trace_id": "address-tWfGlq9Bif",
|
||||||
|
"prompt_version": "address_query_runtime_v1",
|
||||||
|
"schema_version": "address_query_runtime_v1",
|
||||||
|
"fallback_type": "partial",
|
||||||
|
"route_summary": null,
|
||||||
|
"fragments": [],
|
||||||
|
"requirements_extracted": [],
|
||||||
|
"coverage_report": {
|
||||||
|
"requirements_total": 0,
|
||||||
|
"requirements_covered": 0,
|
||||||
|
"requirements_uncovered": [],
|
||||||
|
"requirements_partially_covered": [],
|
||||||
|
"clarification_needed_for": [],
|
||||||
|
"out_of_scope_requirements": []
|
||||||
|
},
|
||||||
|
"routes": [],
|
||||||
|
"retrieval_status": [],
|
||||||
|
"retrieval_results": [],
|
||||||
|
"answer_grounding_check": {
|
||||||
|
"status": "partial",
|
||||||
|
"route_subject_match": true,
|
||||||
|
"missing_requirements": [],
|
||||||
|
"reasons": [
|
||||||
|
"address_action_detected",
|
||||||
|
"address_entity_detected",
|
||||||
|
"aggregate_signal_detected",
|
||||||
|
"account_balance_signal_detected"
|
||||||
|
],
|
||||||
|
"why_included_summary": [],
|
||||||
|
"selection_reason_summary": []
|
||||||
|
},
|
||||||
|
"dropped_intent_segments": [],
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_mode_confidence": "high",
|
||||||
|
"query_shape": "AGGREGATE_LOOKUP",
|
||||||
|
"query_shape_confidence": "high",
|
||||||
|
"detected_intent": "account_balance_snapshot",
|
||||||
|
"detected_intent_confidence": "high",
|
||||||
|
"extracted_filters": {
|
||||||
|
"sort": "period_desc",
|
||||||
|
"limit": 20,
|
||||||
|
"account": "76",
|
||||||
|
"as_of_date": "2020-12-31"
|
||||||
|
},
|
||||||
|
"missing_required_filters": [],
|
||||||
|
"selected_recipe": "address_movements_account_snapshot_v1",
|
||||||
|
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
|
||||||
|
"account_scope_mode": "strict",
|
||||||
|
"account_scope_fallback_applied": false,
|
||||||
|
"anchor_type": "account",
|
||||||
|
"anchor_value_raw": "76",
|
||||||
|
"anchor_value_resolved": "76",
|
||||||
|
"resolver_confidence": "high",
|
||||||
|
"ambiguity_count": 0,
|
||||||
|
"match_failure_stage": "none",
|
||||||
|
"match_failure_reason": null,
|
||||||
|
"mcp_call_status": "raw_rows_received_but_not_materialized",
|
||||||
|
"rows_fetched": 20,
|
||||||
|
"raw_rows_received": 20,
|
||||||
|
"rows_after_account_scope": 0,
|
||||||
|
"rows_after_recipe_filter": 0,
|
||||||
|
"rows_materialized": 0,
|
||||||
|
"rows_matched": 0,
|
||||||
|
"raw_row_keys_sample": [
|
||||||
|
"Период",
|
||||||
|
"Регистратор",
|
||||||
|
"СчетДт",
|
||||||
|
"СчетКт",
|
||||||
|
"Сумма",
|
||||||
|
"Period",
|
||||||
|
"Registrator",
|
||||||
|
"AccountDt",
|
||||||
|
"AccountKt",
|
||||||
|
"Amount"
|
||||||
|
],
|
||||||
|
"materialization_drop_reason": "dropped_by_account_scope_filter",
|
||||||
|
"account_token_raw": "76",
|
||||||
|
"account_token_normalized": "76",
|
||||||
|
"account_scope_fields_checked": [
|
||||||
|
"account_dt",
|
||||||
|
"account_kt",
|
||||||
|
"registrator",
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
|
||||||
|
"account_scope_drop_reason": "no_rows_after_scope_filter",
|
||||||
|
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
"limited_reason_category": "empty_match",
|
||||||
|
"response_type": "LIMITED_WITH_REASON",
|
||||||
|
"answer_structure_v11": null,
|
||||||
|
"investigation_state_snapshot": null,
|
||||||
|
"normalized": null,
|
||||||
|
"normalizer_output": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. user
|
||||||
|
message_id: msg-_0vMruaosw
|
||||||
|
created_at: 2026-04-01T12:32:52.525Z
|
||||||
|
reply_type: n/a
|
||||||
|
|
||||||
|
Какие документы формируют остаток по счету 62 на дату 2020-07-31?
|
||||||
|
|
||||||
|
## 6. assistant
|
||||||
|
message_id: msg-vDgQf1_P6u
|
||||||
|
created_at: 2026-04-01T12:32:53.206Z
|
||||||
|
reply_type: partial_coverage
|
||||||
|
trace_id: address-D2al1c11rV
|
||||||
|
|
||||||
|
В live-данных по текущему фильтру записи не найдены.
|
||||||
|
Причина: по выбранным фильтрам в live-выборке нет строк.
|
||||||
|
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
|
||||||
|
|
||||||
|
### technical_debug_payload_json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trace_id": "address-D2al1c11rV",
|
||||||
|
"prompt_version": "address_query_runtime_v1",
|
||||||
|
"schema_version": "address_query_runtime_v1",
|
||||||
|
"fallback_type": "partial",
|
||||||
|
"route_summary": null,
|
||||||
|
"fragments": [],
|
||||||
|
"requirements_extracted": [],
|
||||||
|
"coverage_report": {
|
||||||
|
"requirements_total": 0,
|
||||||
|
"requirements_covered": 0,
|
||||||
|
"requirements_uncovered": [],
|
||||||
|
"requirements_partially_covered": [],
|
||||||
|
"clarification_needed_for": [],
|
||||||
|
"out_of_scope_requirements": []
|
||||||
|
},
|
||||||
|
"routes": [],
|
||||||
|
"retrieval_status": [],
|
||||||
|
"retrieval_results": [],
|
||||||
|
"answer_grounding_check": {
|
||||||
|
"status": "partial",
|
||||||
|
"route_subject_match": true,
|
||||||
|
"missing_requirements": [],
|
||||||
|
"reasons": [
|
||||||
|
"address_action_detected",
|
||||||
|
"address_entity_detected",
|
||||||
|
"drilldown_signal_detected",
|
||||||
|
"documents_forming_balance_signal_detected"
|
||||||
|
],
|
||||||
|
"why_included_summary": [],
|
||||||
|
"selection_reason_summary": []
|
||||||
|
},
|
||||||
|
"dropped_intent_segments": [],
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_mode_confidence": "high",
|
||||||
|
"query_shape": "DRILLDOWN_REQUEST",
|
||||||
|
"query_shape_confidence": "high",
|
||||||
|
"detected_intent": "documents_forming_balance",
|
||||||
|
"detected_intent_confidence": "high",
|
||||||
|
"extracted_filters": {
|
||||||
|
"sort": "period_desc",
|
||||||
|
"limit": 20,
|
||||||
|
"account": "62",
|
||||||
|
"as_of_date": "2020-07-31"
|
||||||
|
},
|
||||||
|
"missing_required_filters": [],
|
||||||
|
"selected_recipe": "address_documents_forming_balance_v1",
|
||||||
|
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
|
||||||
|
"account_scope_mode": "strict",
|
||||||
|
"account_scope_fallback_applied": false,
|
||||||
|
"anchor_type": "account",
|
||||||
|
"anchor_value_raw": "62",
|
||||||
|
"anchor_value_resolved": "62",
|
||||||
|
"resolver_confidence": "high",
|
||||||
|
"ambiguity_count": 0,
|
||||||
|
"match_failure_stage": "none",
|
||||||
|
"match_failure_reason": null,
|
||||||
|
"mcp_call_status": "raw_rows_received_but_not_materialized",
|
||||||
|
"rows_fetched": 20,
|
||||||
|
"raw_rows_received": 20,
|
||||||
|
"rows_after_account_scope": 0,
|
||||||
|
"rows_after_recipe_filter": 0,
|
||||||
|
"rows_materialized": 0,
|
||||||
|
"rows_matched": 0,
|
||||||
|
"raw_row_keys_sample": [
|
||||||
|
"Период",
|
||||||
|
"Регистратор",
|
||||||
|
"СчетДт",
|
||||||
|
"СчетКт",
|
||||||
|
"Сумма",
|
||||||
|
"Period",
|
||||||
|
"Registrator",
|
||||||
|
"AccountDt",
|
||||||
|
"AccountKt",
|
||||||
|
"Amount"
|
||||||
|
],
|
||||||
|
"materialization_drop_reason": "dropped_by_account_scope_filter",
|
||||||
|
"account_token_raw": "62",
|
||||||
|
"account_token_normalized": "62",
|
||||||
|
"account_scope_fields_checked": [
|
||||||
|
"account_dt",
|
||||||
|
"account_kt",
|
||||||
|
"registrator",
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
|
||||||
|
"account_scope_drop_reason": "no_rows_after_scope_filter",
|
||||||
|
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
"limited_reason_category": "empty_match",
|
||||||
|
"response_type": "LIMITED_WITH_REASON",
|
||||||
|
"answer_structure_v11": null,
|
||||||
|
"investigation_state_snapshot": null,
|
||||||
|
"normalized": null,
|
||||||
|
"normalizer_output": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. user
|
||||||
|
message_id: msg-n4DAnggWmb
|
||||||
|
created_at: 2026-04-01T12:33:02.902Z
|
||||||
|
reply_type: n/a
|
||||||
|
|
||||||
|
Что формирует остаток по счету 76 на дату 2020-12-31?
|
||||||
|
|
||||||
|
## 8. assistant
|
||||||
|
message_id: msg-46fXowQ8cs
|
||||||
|
created_at: 2026-04-01T12:33:03.352Z
|
||||||
|
reply_type: partial_coverage
|
||||||
|
trace_id: address-o7q7qa-FOF
|
||||||
|
|
||||||
|
В live-данных по текущему фильтру записи не найдены.
|
||||||
|
Причина: по выбранным фильтрам в live-выборке нет строк.
|
||||||
|
Что нужно уточнить: уточните период, контрагента, договор или снимите часть фильтров.
|
||||||
|
|
||||||
|
### technical_debug_payload_json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trace_id": "address-o7q7qa-FOF",
|
||||||
|
"prompt_version": "address_query_runtime_v1",
|
||||||
|
"schema_version": "address_query_runtime_v1",
|
||||||
|
"fallback_type": "partial",
|
||||||
|
"route_summary": null,
|
||||||
|
"fragments": [],
|
||||||
|
"requirements_extracted": [],
|
||||||
|
"coverage_report": {
|
||||||
|
"requirements_total": 0,
|
||||||
|
"requirements_covered": 0,
|
||||||
|
"requirements_uncovered": [],
|
||||||
|
"requirements_partially_covered": [],
|
||||||
|
"clarification_needed_for": [],
|
||||||
|
"out_of_scope_requirements": []
|
||||||
|
},
|
||||||
|
"routes": [],
|
||||||
|
"retrieval_status": [],
|
||||||
|
"retrieval_results": [],
|
||||||
|
"answer_grounding_check": {
|
||||||
|
"status": "partial",
|
||||||
|
"route_subject_match": true,
|
||||||
|
"missing_requirements": [],
|
||||||
|
"reasons": [
|
||||||
|
"address_action_detected",
|
||||||
|
"address_entity_detected",
|
||||||
|
"aggregate_signal_detected",
|
||||||
|
"account_balance_signal_detected"
|
||||||
|
],
|
||||||
|
"why_included_summary": [],
|
||||||
|
"selection_reason_summary": []
|
||||||
|
},
|
||||||
|
"dropped_intent_segments": [],
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_mode_confidence": "high",
|
||||||
|
"query_shape": "AGGREGATE_LOOKUP",
|
||||||
|
"query_shape_confidence": "high",
|
||||||
|
"detected_intent": "account_balance_snapshot",
|
||||||
|
"detected_intent_confidence": "high",
|
||||||
|
"extracted_filters": {
|
||||||
|
"sort": "period_desc",
|
||||||
|
"limit": 20,
|
||||||
|
"account": "76",
|
||||||
|
"as_of_date": "2020-12-31"
|
||||||
|
},
|
||||||
|
"missing_required_filters": [],
|
||||||
|
"selected_recipe": "address_movements_account_snapshot_v1",
|
||||||
|
"mcp_call_status_legacy": "raw_rows_received_but_not_materialized",
|
||||||
|
"account_scope_mode": "strict",
|
||||||
|
"account_scope_fallback_applied": false,
|
||||||
|
"anchor_type": "account",
|
||||||
|
"anchor_value_raw": "76",
|
||||||
|
"anchor_value_resolved": "76",
|
||||||
|
"resolver_confidence": "high",
|
||||||
|
"ambiguity_count": 0,
|
||||||
|
"match_failure_stage": "none",
|
||||||
|
"match_failure_reason": null,
|
||||||
|
"mcp_call_status": "raw_rows_received_but_not_materialized",
|
||||||
|
"rows_fetched": 20,
|
||||||
|
"raw_rows_received": 20,
|
||||||
|
"rows_after_account_scope": 0,
|
||||||
|
"rows_after_recipe_filter": 0,
|
||||||
|
"rows_materialized": 0,
|
||||||
|
"rows_matched": 0,
|
||||||
|
"raw_row_keys_sample": [
|
||||||
|
"Период",
|
||||||
|
"Регистратор",
|
||||||
|
"СчетДт",
|
||||||
|
"СчетКт",
|
||||||
|
"Сумма",
|
||||||
|
"Period",
|
||||||
|
"Registrator",
|
||||||
|
"AccountDt",
|
||||||
|
"AccountKt",
|
||||||
|
"Amount"
|
||||||
|
],
|
||||||
|
"materialization_drop_reason": "dropped_by_account_scope_filter",
|
||||||
|
"account_token_raw": "76",
|
||||||
|
"account_token_normalized": "76",
|
||||||
|
"account_scope_fields_checked": [
|
||||||
|
"account_dt",
|
||||||
|
"account_kt",
|
||||||
|
"registrator",
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
|
||||||
|
"account_scope_drop_reason": "no_rows_after_scope_filter",
|
||||||
|
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
"limited_reason_category": "empty_match",
|
||||||
|
"response_type": "LIMITED_WITH_REASON",
|
||||||
|
"answer_structure_v11": null,
|
||||||
|
"investigation_state_snapshot": null,
|
||||||
|
"normalized": null,
|
||||||
|
"normalizer_output": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. user
|
||||||
|
message_id: msg-ATLBoTgwlD
|
||||||
|
created_at: 2026-04-01T12:33:20.966Z
|
||||||
|
reply_type: n/a
|
||||||
|
|
||||||
|
Покажи банковские операции по контрагенту СВК с 2020-07-01 по 2020-07-31
|
||||||
|
|
||||||
|
## 10. assistant
|
||||||
|
message_id: msg-p1biGQOTcr
|
||||||
|
created_at: 2026-04-01T12:33:21.620Z
|
||||||
|
reply_type: factual
|
||||||
|
trace_id: address-JwD-3OLHgc
|
||||||
|
|
||||||
|
Собран список банковских операций по контрагенту (live address lane).
|
||||||
|
Строк отобрано: 2.
|
||||||
|
1. 2020-07-30T12:00:00Z | Поступление на расчетный счет 00000000029 от 30.07.2020 12:00:00 | 0 / 0 | 20000 | аналитика: Группа СВК
|
||||||
|
2. 2020-07-27T12:00:00Z | Поступление на расчетный счет 00000000028 от 27.07.2020 12:00:00 | 0 / 0 | 40860 | аналитика: Группа СВК
|
||||||
|
|
||||||
|
### technical_debug_payload_json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trace_id": "address-JwD-3OLHgc",
|
||||||
|
"prompt_version": "address_query_runtime_v1",
|
||||||
|
"schema_version": "address_query_runtime_v1",
|
||||||
|
"fallback_type": "none",
|
||||||
|
"route_summary": null,
|
||||||
|
"fragments": [],
|
||||||
|
"requirements_extracted": [],
|
||||||
|
"coverage_report": {
|
||||||
|
"requirements_total": 0,
|
||||||
|
"requirements_covered": 0,
|
||||||
|
"requirements_uncovered": [],
|
||||||
|
"requirements_partially_covered": [],
|
||||||
|
"clarification_needed_for": [],
|
||||||
|
"out_of_scope_requirements": []
|
||||||
|
},
|
||||||
|
"routes": [],
|
||||||
|
"retrieval_status": [],
|
||||||
|
"retrieval_results": [],
|
||||||
|
"answer_grounding_check": {
|
||||||
|
"status": "grounded",
|
||||||
|
"route_subject_match": true,
|
||||||
|
"missing_requirements": [],
|
||||||
|
"reasons": [
|
||||||
|
"address_action_detected",
|
||||||
|
"address_entity_detected",
|
||||||
|
"object_signal_detected",
|
||||||
|
"bank_ops_by_counterparty_signal_detected"
|
||||||
|
],
|
||||||
|
"why_included_summary": [],
|
||||||
|
"selection_reason_summary": []
|
||||||
|
},
|
||||||
|
"dropped_intent_segments": [],
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_mode_confidence": "high",
|
||||||
|
"query_shape": "OBJECT_LOOKUP",
|
||||||
|
"query_shape_confidence": "medium",
|
||||||
|
"detected_intent": "bank_operations_by_counterparty",
|
||||||
|
"detected_intent_confidence": "medium",
|
||||||
|
"extracted_filters": {
|
||||||
|
"sort": "period_desc",
|
||||||
|
"limit": 20,
|
||||||
|
"counterparty": "СВК",
|
||||||
|
"period_from": "2020-07-01",
|
||||||
|
"period_to": "2020-07-31"
|
||||||
|
},
|
||||||
|
"missing_required_filters": [],
|
||||||
|
"selected_recipe": "address_bank_operations_by_counterparty_v1",
|
||||||
|
"mcp_call_status_legacy": "matched_non_empty",
|
||||||
|
"account_scope_mode": "preferred",
|
||||||
|
"account_scope_fallback_applied": false,
|
||||||
|
"anchor_type": "counterparty",
|
||||||
|
"anchor_value_raw": "СВК",
|
||||||
|
"anchor_value_resolved": "Группа СВК",
|
||||||
|
"resolver_confidence": "medium",
|
||||||
|
"ambiguity_count": 0,
|
||||||
|
"match_failure_stage": "none",
|
||||||
|
"match_failure_reason": null,
|
||||||
|
"mcp_call_status": "matched_non_empty",
|
||||||
|
"rows_fetched": 19,
|
||||||
|
"raw_rows_received": 19,
|
||||||
|
"rows_after_account_scope": 3,
|
||||||
|
"rows_after_recipe_filter": 2,
|
||||||
|
"rows_materialized": 3,
|
||||||
|
"rows_matched": 2,
|
||||||
|
"raw_row_keys_sample": [
|
||||||
|
"Период",
|
||||||
|
"Регистратор",
|
||||||
|
"СчетДт",
|
||||||
|
"СчетКт",
|
||||||
|
"Сумма",
|
||||||
|
"Контрагент",
|
||||||
|
"Period",
|
||||||
|
"Registrator",
|
||||||
|
"Amount"
|
||||||
|
],
|
||||||
|
"materialization_drop_reason": "none",
|
||||||
|
"account_token_raw": null,
|
||||||
|
"account_token_normalized": null,
|
||||||
|
"account_scope_fields_checked": [
|
||||||
|
"account_dt",
|
||||||
|
"account_kt",
|
||||||
|
"registrator",
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"account_scope_match_strategy": "account_code_regex_plus_alias_map_v1",
|
||||||
|
"account_scope_drop_reason": "not_applicable",
|
||||||
|
"runtime_readiness": "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
"limited_reason_category": null,
|
||||||
|
"response_type": "FACTUAL_LIST",
|
||||||
|
"answer_structure_v11": null,
|
||||||
|
"investigation_state_snapshot": null,
|
||||||
|
"normalized": null,
|
||||||
|
"normalizer_output": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
- `query_recipes_v1.md` - каталог фильтров и recipe-контракты.
|
- `query_recipes_v1.md` - каталог фильтров и recipe-контракты.
|
||||||
- `runtime_integration_plan.md` - план встраивания `question_mode=address_query`.
|
- `runtime_integration_plan.md` - план встраивания `question_mode=address_query`.
|
||||||
- `address_runtime_contracts.md` - контракты runtime/debug/result для address lane.
|
- `address_runtime_contracts.md` - контракты runtime/debug/result для address lane.
|
||||||
|
- `address_architecture_contract_v1.md` - архитектурные границы `Decompose -> Resolve -> Execute -> Compose` и политика data-agnostic runtime.
|
||||||
- `runtime_readiness_matrix_v1.md` - матрица structural vs runtime readiness.
|
- `runtime_readiness_matrix_v1.md` - матрица structural vs runtime readiness.
|
||||||
- `known_positive_live_suite_v1.md` - базовый template positive-evidence suite.
|
- `known_positive_live_suite_v1.md` - базовый template positive-evidence suite.
|
||||||
- `data_aware_positive_acceptance_suite_v1.md` - M2.3 canonical guide для curated live acceptance.
|
- `data_aware_positive_acceptance_suite_v1.md` - M2.3 canonical guide для curated live acceptance.
|
||||||
|
|
@ -23,4 +24,5 @@
|
||||||
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3A_Stage_Diagnostic_Materialization/`
|
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3A_Stage_Diagnostic_Materialization/`
|
||||||
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3B_AccountScope_Mode_Tuning/`
|
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3B_AccountScope_Mode_Tuning/`
|
||||||
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/`
|
- `docs/ADDRESS/runs/2026-03-29_Address_Query_Runtime_V1_M2_3C_Resolver_Filter_Tuning_And_AccountScope_Audit/`
|
||||||
|
- `docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Address Architecture Contract V1
|
||||||
|
|
||||||
|
Дата: 2026-04-01
|
||||||
|
|
||||||
|
## 1) Зачем документ
|
||||||
|
|
||||||
|
Этот контракт фиксирует архитектурные границы `address_query`-контура, чтобы система оставалась переносимой между разными 1С-базами и не обрастала company-specific логикой.
|
||||||
|
|
||||||
|
Контракт обязателен для всех следующих инкрементов (`M2.4+`), рефакторов и новых intent/recipe.
|
||||||
|
|
||||||
|
## 2) Непересекаемые принципы
|
||||||
|
|
||||||
|
- `MCP/live-first`: основной источник фактов - live MCP.
|
||||||
|
- `MSP-only` в runtime: production path работает через MCP/MSP; snapshot - только controlled fallback.
|
||||||
|
- `snapshot` допускается только как явный fallback с reason code, а не как скрытая подмена.
|
||||||
|
- `runtime = data-agnostic`: никаких хардкодов под конкретную компанию.
|
||||||
|
- `acceptance = data-aware`: positive-кейсы можно подбирать на текущей базе только для проверки.
|
||||||
|
- `false_factual_rate = 0`: factual-ответ только при подтвержденных `rows_matched > 0`.
|
||||||
|
- `whitelist execution only`: никаких свободных NL->query генераторов.
|
||||||
|
|
||||||
|
## 3) Канонический pipeline
|
||||||
|
|
||||||
|
## Stage A: Decompose
|
||||||
|
|
||||||
|
Назначение: интерпретация текста вопроса, без обращения к данным компании.
|
||||||
|
|
||||||
|
Выход stage:
|
||||||
|
|
||||||
|
- `question_mode`
|
||||||
|
- `query_shape`
|
||||||
|
- `intent_candidates`
|
||||||
|
- `anchors_raw`
|
||||||
|
- `time_scope_raw`
|
||||||
|
- `filters_raw`
|
||||||
|
- `decomposition_plan` (опционально, для compound)
|
||||||
|
|
||||||
|
Запрещено на этапе Decompose:
|
||||||
|
|
||||||
|
- резолвить реальные объекты базы (контрагентов, договоры, документы);
|
||||||
|
- тянуть company-specific словари;
|
||||||
|
- генерировать запросы к 1С.
|
||||||
|
|
||||||
|
## Stage B: Resolve
|
||||||
|
|
||||||
|
Назначение: привязка raw-якорей к живым объектам через MCP.
|
||||||
|
|
||||||
|
Выход stage:
|
||||||
|
|
||||||
|
- `anchor_type`
|
||||||
|
- `anchor_value_raw`
|
||||||
|
- `anchor_value_resolved`
|
||||||
|
- `resolver_confidence`
|
||||||
|
- `ambiguity_count`
|
||||||
|
|
||||||
|
Правило:
|
||||||
|
|
||||||
|
- если якорь не подтвержден, runtime не выдумывает факт и идет в `LIMITED_WITH_REASON`.
|
||||||
|
|
||||||
|
## Stage C: Execute
|
||||||
|
|
||||||
|
Назначение: выполнение только через recipe whitelist.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- `intent -> recipe_id` только из каталога;
|
||||||
|
- fixed `limit/sort/window` политика;
|
||||||
|
- `MCP` read-only;
|
||||||
|
- `MSP/MCP-only` execution path в production;
|
||||||
|
- snapshot fallback только явный.
|
||||||
|
|
||||||
|
Диагностика по стадиям:
|
||||||
|
|
||||||
|
- `no_raw_rows`
|
||||||
|
- `raw_rows_received_but_not_materialized`
|
||||||
|
- `materialized_but_not_anchor_matched`
|
||||||
|
- `materialized_but_filtered_out_by_recipe`
|
||||||
|
- `matched_non_empty`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
## Stage D: Compose
|
||||||
|
|
||||||
|
Назначение: финальный ответ строго по execution-результату.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- factual только из `rows_matched`;
|
||||||
|
- если пусто - `LIMITED_WITH_REASON` с конкретной причиной;
|
||||||
|
- без reasoning-галлюцинаций и без “догадки по смыслу”.
|
||||||
|
|
||||||
|
## 4) Политика словарей
|
||||||
|
|
||||||
|
Разрешено (статически в коде):
|
||||||
|
|
||||||
|
- доменная типовая лексика (`доки`, `остаток`, `договор`, `дебиторка` и т.д.);
|
||||||
|
- правила парсинга дат/периодов/счетов;
|
||||||
|
- stop-слова и служебные alias-правила.
|
||||||
|
|
||||||
|
Запрещено:
|
||||||
|
|
||||||
|
- хранить в коде списки компаний, ИНН, договоров, документов конкретной базы;
|
||||||
|
- пополнять глобальные normalization-библиотеки живыми entity-именами;
|
||||||
|
- строить скрытые “памяти компании” вне runtime-сессии.
|
||||||
|
|
||||||
|
Допустимо:
|
||||||
|
|
||||||
|
- использовать runtime-сессионный контекст диалога (`followup context`) без записи в глобальные словари.
|
||||||
|
|
||||||
|
## 5) Критерии переносимости между компаниями
|
||||||
|
|
||||||
|
Система считается переносимой, если:
|
||||||
|
|
||||||
|
- новая база подключается без code change в resolver/intent logic;
|
||||||
|
- меняются только live-данные MCP, а не кодовые словари;
|
||||||
|
- question-bank остается валиден (с ожидаемыми различиями factual/limited по данным).
|
||||||
|
|
||||||
|
## 6) Антипаттерны (нельзя делать)
|
||||||
|
|
||||||
|
- Добавлять company alias map в `src/services/*` с реальными названиями контрагентов.
|
||||||
|
- Перекладывать проблему резолвинга в hardcoded `if company == ...`.
|
||||||
|
- Подмешивать deep-analysis ответ в address factual-блок без явного route handoff.
|
||||||
|
- Поднимать “временные” exceptions, которые ломают stage-контракт.
|
||||||
|
|
||||||
|
## 7) Техническая дисциплина кода
|
||||||
|
|
||||||
|
Новая логика должна ложиться в явные stage-модули:
|
||||||
|
|
||||||
|
- decompose
|
||||||
|
- resolve
|
||||||
|
- execute
|
||||||
|
- compose
|
||||||
|
- diagnostics
|
||||||
|
|
||||||
|
Если функция не относится к stage - это smell и повод к вынесению.
|
||||||
|
|
||||||
|
## 8) Как подключать LLM decompose
|
||||||
|
|
||||||
|
LLM на первом этапе нужен не для “знания компании”, а для структурной интерпретации вопроса.
|
||||||
|
|
||||||
|
LLM должен возвращать схему-stage-output:
|
||||||
|
|
||||||
|
- intent candidates
|
||||||
|
- shape
|
||||||
|
- anchor spans
|
||||||
|
- time scope
|
||||||
|
- filter hints
|
||||||
|
- confidence
|
||||||
|
|
||||||
|
Дальше все company-specific подтверждается только Resolver/Execute через MCP.
|
||||||
|
|
||||||
|
Итог:
|
||||||
|
|
||||||
|
- LLM decompose уменьшает NLP-хрупкость;
|
||||||
|
- не требует жирных живых словарей компаний;
|
||||||
|
- не нарушает data-agnostic принцип runtime.
|
||||||
|
|
@ -2,11 +2,18 @@
|
||||||
|
|
||||||
Дата: 2026-03-29
|
Дата: 2026-03-29
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- `address_architecture_contract_v1.md` (architecture guardrails and stage boundaries).
|
||||||
|
|
||||||
## Runtime Policy
|
## Runtime Policy
|
||||||
|
|
||||||
- Runtime lane is `data-agnostic`: no hardcoded counterparties/contracts/accounts from one concrete base.
|
- Runtime lane is `data-agnostic`: no hardcoded counterparties/contracts/accounts from one concrete base.
|
||||||
- Acceptance lane is `data-aware`: positive cases are curated after exploratory live pass.
|
- Acceptance lane is `data-aware`: positive cases are curated after exploratory live pass.
|
||||||
- Address lane remains MCP/live-first, whitelist-only, read-only.
|
- Address lane remains MCP/live-first, whitelist-only, read-only.
|
||||||
|
- Runtime execution is MSP-only in production; snapshot usage is explicit fallback only.
|
||||||
|
- Canonical pipeline boundary: `Decompose -> Resolve -> Execute -> Compose` (no cross-stage leakage).
|
||||||
|
- LLM decompose stage interprets question structure only; company entities are resolved only in live resolver stage.
|
||||||
|
|
||||||
## Input Contract
|
## Input Contract
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -10,6 +10,9 @@
|
||||||
- какие хвосты висят по договору
|
- какие хвосты висят по договору
|
||||||
- у кого самый большой долг перед нами
|
- у кого самый большой долг перед нами
|
||||||
- кому больше всего должны мы
|
- кому больше всего должны мы
|
||||||
|
- покажи дебиторку по контрагентам на дату
|
||||||
|
- покажи кредиторку по поставщикам на дату
|
||||||
|
- что висит по взаиморасчетам на текущую дату
|
||||||
|
|
||||||
## B. Счета и остатки
|
## B. Счета и остатки
|
||||||
|
|
||||||
|
|
@ -18,6 +21,9 @@
|
||||||
- что висит на 60 счете
|
- что висит на 60 счете
|
||||||
- какие документы формируют остаток по 62
|
- какие документы формируют остаток по 62
|
||||||
- оборот по 60 за период
|
- оборот по 60 за период
|
||||||
|
- раскрой остаток по счету 62 до документов
|
||||||
|
- покажи сальдо по счету 60.01 на дату
|
||||||
|
- из чего сложился остаток по 76 счету
|
||||||
|
|
||||||
## C. Договоры
|
## C. Договоры
|
||||||
|
|
||||||
|
|
@ -25,6 +31,9 @@
|
||||||
- что по договору 15/24
|
- что по договору 15/24
|
||||||
- есть ли долг по договору с Альфой
|
- есть ли долг по договору с Альфой
|
||||||
- какие документы связаны с этим договором
|
- какие документы связаны с этим договором
|
||||||
|
- покажи незакрытые договоры по контрагенту
|
||||||
|
- какие хвосты по договору №15/24 на дату
|
||||||
|
- есть ли открытые позиции по договору
|
||||||
|
|
||||||
## D. Документы
|
## D. Документы
|
||||||
|
|
||||||
|
|
@ -32,6 +41,9 @@
|
||||||
- покажи документы по договору за период
|
- покажи документы по договору за период
|
||||||
- найди документ по номеру и дате
|
- найди документ по номеру и дате
|
||||||
- покажи проведенные документы по организации
|
- покажи проведенные документы по организации
|
||||||
|
- какие документы доступны по компании СВК за 2021 год
|
||||||
|
- выведи документы по клиенту Бета за июль 2020
|
||||||
|
- покажи документы по поставщику Альфа за весь период
|
||||||
|
|
||||||
## E. Bank/Payment lookup
|
## E. Bank/Payment lookup
|
||||||
|
|
||||||
|
|
@ -39,24 +51,33 @@
|
||||||
- были ли поступления от Беты
|
- были ли поступления от Беты
|
||||||
- покажи списания с расчетного счета по договору
|
- покажи списания с расчетного счета по договору
|
||||||
- найди оплату на сумму 150000
|
- найди оплату на сумму 150000
|
||||||
|
- покажи банковские операции по контрагенту Альфа
|
||||||
|
- покажи поступления и списания по клиенту Бета
|
||||||
|
- выведи bank operations by counterparty Alfa for all time
|
||||||
|
|
||||||
## F. Drilldown
|
## F. Drilldown
|
||||||
|
|
||||||
- кто должен нам и какие документы это формируют
|
- кто должен нам и какие документы это формируют
|
||||||
- что висит по Альфе и раскрой по документам
|
- что висит по Альфе и раскрой по документам
|
||||||
- по 60 счету что висит и раскрой по контрагентам
|
- по 60 счету что висит и раскрой по контрагентам
|
||||||
|
- какие документы формируют остаток по счету 62 на 2020-07-31
|
||||||
|
- расшифруй остаток по 60 счету до документов
|
||||||
|
|
||||||
## G. Составные factual вопросы
|
## G. Составные factual вопросы
|
||||||
|
|
||||||
- покажи хвосты по Альфе и отдельно по Бете
|
- покажи хвосты по Альфе и отдельно по Бете
|
||||||
- кто должен нам и отдельно кому должны мы
|
- кто должен нам и отдельно кому должны мы
|
||||||
- найди долг по договору и покажи документы
|
- найди долг по договору и покажи документы
|
||||||
|
- покажи документы по контрагенту и сразу банковские операции
|
||||||
|
- остаток по счету 60 и какие документы его формируют
|
||||||
|
|
||||||
## H. Check/verify (still factual)
|
## H. Check/verify (still factual)
|
||||||
|
|
||||||
- проверь, есть ли долг по Альфе
|
- проверь, есть ли долг по Альфе
|
||||||
- проверь, есть ли незакрытые документы
|
- проверь, есть ли незакрытые документы
|
||||||
- проверь, что висит на 60 счете
|
- проверь, что висит на 60 счете
|
||||||
|
- проверь, есть ли открытые позиции по договору
|
||||||
|
- проверь, есть ли документы по контрагенту за июль 2020
|
||||||
|
|
||||||
## Правило маршрутизации
|
## Правило маршрутизации
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,18 @@
|
||||||
- `llm_normalizer/backend/src/services/assistantRuntimeGuards.ts`
|
- `llm_normalizer/backend/src/services/assistantRuntimeGuards.ts`
|
||||||
- `llm_normalizer/backend/src/services/answerComposer.ts`
|
- `llm_normalizer/backend/src/services/answerComposer.ts`
|
||||||
|
|
||||||
|
## 2.1) Architecture Reference (mandatory)
|
||||||
|
|
||||||
|
Перед любыми изменениями address lane сверяться с:
|
||||||
|
|
||||||
|
- `address_architecture_contract_v1.md`
|
||||||
|
|
||||||
|
Ключевая рамка:
|
||||||
|
|
||||||
|
- `Decompose -> Resolve -> Execute -> Compose`
|
||||||
|
- runtime не хранит company-specific словари
|
||||||
|
- company entities подтверждаются только через live resolver/MCP
|
||||||
|
|
||||||
## 3) To-Be: Separate Address Lane
|
## 3) To-Be: Separate Address Lane
|
||||||
|
|
||||||
Новый high-level flow:
|
Новый high-level flow:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Address Query Runtime V1 — M2.3d Query Variants Expansion
|
||||||
|
|
||||||
|
Дата: 2026-04-01
|
||||||
|
|
||||||
|
## Цель шага
|
||||||
|
|
||||||
|
Подбить текущий этап M2.3c и расширить вариативность пользовательских формулировок для уже существующих intent'ов без изменения архитектурной рамки.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
1. Расширены сигналы address-mode:
|
||||||
|
- добавлены entity-токены `компания/организация/поставщик/клиент/партнер` и английские эквиваленты.
|
||||||
|
|
||||||
|
2. Расширен intent-resolver:
|
||||||
|
- добавлены вариации `documents_by_counterparty` и `bank_operations_by_counterparty` для формулировок через поставщика/клиента/компанию;
|
||||||
|
- добаван loose by-anchor trigger (`по <якорь>`) для short/slang формулировок.
|
||||||
|
|
||||||
|
3. Усилен filter-extractor:
|
||||||
|
- расширен extraction якоря контрагента (`по поставщику`, `по клиенту`, `по партнеру`, `по организации`, `by supplier/customer/client/partner`);
|
||||||
|
- добавлены all-time варианты (`за весь период`, `за весь срок`, `за всю историю`, `for entire period`, `full history`);
|
||||||
|
- добавлен month-period extraction (`за июль 2020`, `for july 2020`, `за 07.2020`);
|
||||||
|
- добавлен fallback extraction по loose phrase (`по свк`).
|
||||||
|
|
||||||
|
4. Закрыт регресс из live export:
|
||||||
|
- `какие доки есть по свк за 2021` больше не уходит в deep/canonical fallback;
|
||||||
|
- запрос остаётся в `address_query` и резолвится как `list_documents_by_counterparty`.
|
||||||
|
|
||||||
|
5. Расширен тестовый контур:
|
||||||
|
- добавлены новые unit-тесты в `addressQueryRuntimeM23.test.ts` под новые вариации и регрессионный кейс.
|
||||||
|
|
||||||
|
## Что НЕ меняли
|
||||||
|
|
||||||
|
- Не добавлялись новые intent'ы.
|
||||||
|
- Не менялась deep-analysis ветка.
|
||||||
|
- Не добавлялся free-form query builder.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- Команда: `npx vitest tests/addressQueryRuntimeM23.test.ts`
|
||||||
|
- Результат: `1 file passed, 25 tests passed`
|
||||||
|
|
||||||
|
## Следующий шаг
|
||||||
|
|
||||||
|
- Запустить dry/live прогон в окне ассистента на расширенном наборе формулировок и зафиксировать non-empty/limited распределение по counterparty/account семействам.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"run_id": "2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion",
|
||||||
|
"comparison": {
|
||||||
|
"baseline_ref": "HEAD (before M2.3d patch)",
|
||||||
|
"current_ref": "workspace (after M2.3d patch + regression fix)"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"address_m23_test_cases": {
|
||||||
|
"before": 15,
|
||||||
|
"after": 25,
|
||||||
|
"delta": 10
|
||||||
|
},
|
||||||
|
"address_m23_test_failures": {
|
||||||
|
"before": 0,
|
||||||
|
"after": 0,
|
||||||
|
"delta": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": [
|
||||||
|
"delta reflects additional query-variation coverage for mode, intent, and filters",
|
||||||
|
"includes regression fix for slang docs phrase: 'какие доки есть по svk за 2021'",
|
||||||
|
"no architecture-scope expansion (no new intents)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
llm_normalizer/backend/src/services/addressQueryClassifier.ts
|
||||||
|
llm_normalizer/backend/src/services/addressIntentResolver.ts
|
||||||
|
llm_normalizer/backend/src/services/addressFilterExtractor.ts
|
||||||
|
llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts
|
||||||
|
llm_normalizer/backend/dist/services/addressQueryClassifier.js
|
||||||
|
llm_normalizer/backend/dist/services/addressIntentResolver.js
|
||||||
|
llm_normalizer/backend/dist/services/addressFilterExtractor.js
|
||||||
|
docs/ADDRESS/address_query/question_bank_v1.md
|
||||||
|
docs/ADDRESS/address_query/README.md
|
||||||
|
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/README.md
|
||||||
|
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/run_summary.json
|
||||||
|
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/before_after_metrics.json
|
||||||
|
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/query_variation_expansion_matrix.md
|
||||||
|
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/smoke_checks.md
|
||||||
|
docs/ADDRESS/runs/2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion/changed_files.txt
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Query Variation Expansion Matrix (M2.3d)
|
||||||
|
|
||||||
|
| area | before | after | validation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| address mode entity signals | mostly `контрагент/договор/счет/документ` | added `компания/организация/поставщик/клиент/партнер` (+EN aliases) + `доки/док` slang | `addressQueryRuntimeM23.test.ts` |
|
||||||
|
| docs/bank intent phrasing | counterparty-centric phrasing | supports company/supplier/customer/client/partner phrasing | `addressQueryRuntimeM23.test.ts` |
|
||||||
|
| counterparty anchor extraction | basic `контрагент/компания` capture | adds supplier/client/partner/org variants (+EN aliases) + loose `по <якорь>` fallback | `addressQueryRuntimeM23.test.ts` |
|
||||||
|
| all-time phrase detection | mostly `за все время` | adds `за весь период/срок/всю историю` + EN full-history variants | `addressQueryRuntimeM23.test.ts` |
|
||||||
|
| month period extraction | range/year extraction | adds month phrases (`за июль 2020`, `for july 2020`, `за 07.2020`) | `addressQueryRuntimeM23.test.ts` |
|
||||||
|
|
||||||
|
## Added example phrasings
|
||||||
|
|
||||||
|
- `Покажи платежи по поставщику Альфа за июль 2020`
|
||||||
|
- `Выведи документы по клиенту Бета за 2020-07`
|
||||||
|
- `Покажи банковские операции по клиенту Бета за весь период`
|
||||||
|
- `Какие компании есть в базе`
|
||||||
|
- `Какие доки есть по свк за 2021`
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"run_id": "2026-04-01_Address_Query_Runtime_V1_M2_3D_Query_Variants_Expansion",
|
||||||
|
"date": "2026-04-01",
|
||||||
|
"stage": "Address Query Runtime V1",
|
||||||
|
"wave": "M2.3d",
|
||||||
|
"goal": "Query variants expansion for existing intents + slang/loose-anchor regression fix",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"scope": {
|
||||||
|
"new_intents": false,
|
||||||
|
"deep_analysis_changes": false,
|
||||||
|
"focus": [
|
||||||
|
"mode signals",
|
||||||
|
"intent hints",
|
||||||
|
"counterparty anchor extraction",
|
||||||
|
"all-time phrase handling",
|
||||||
|
"month-period extraction",
|
||||||
|
"slang docs + loose by-anchor handling",
|
||||||
|
"unit test coverage"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"test_command": "npx vitest tests/addressQueryRuntimeM23.test.ts",
|
||||||
|
"test_files_passed": 1,
|
||||||
|
"tests_passed": 25,
|
||||||
|
"tests_failed": 0
|
||||||
|
},
|
||||||
|
"guardrails": {
|
||||||
|
"false_factual_rate_target": 0,
|
||||||
|
"free_form_query_builder": "not_added",
|
||||||
|
"whitelist_recipe_policy": "unchanged"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Smoke Checks
|
||||||
|
|
||||||
|
## Backend tests
|
||||||
|
|
||||||
|
- Command: `npx vitest tests/addressQueryRuntimeM23.test.ts`
|
||||||
|
- Result: PASS
|
||||||
|
- Details: `1 passed file`, `25 passed tests`, `0 failed`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
- Command: `npm run build`
|
||||||
|
- Result: PASS
|
||||||
|
- Details: TypeScript build completed without errors.
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,157 @@
|
||||||
|
|
||||||
|
Отчет по анализу архива address_query
|
||||||
|
Инвентаризация разметки 1С, покрытие доменов и статус runtime-сценариев
|
||||||
|
Источник: архив address_query.zip | Дата документов внутри архива: 2026-03-29
|
||||||
|
Главный вывод
|
||||||
|
Архив посвящен не общему налоговому/НДС-контуру, а отдельному runtime-режиму address_query для factual lookup по взаиморасчетам, договорам, документам, банковским операциям и остаткам по счетам.
|
||||||
|
Масштаб inventory
|
||||||
|
Обработано 7 039 648 строк из 12 monthly snapshot-файлов за 2020 год, parse errors = 0, найдено 286 уникальных сущностей в 10 family-группах.
|
||||||
|
Что реально покрыто сейчас
|
||||||
|
На уровне V1/M2.3c подтверждены прежде всего сценарии по контрагентам и документам; account-сценарии видны, но еще ограничены materialization/account-scope проблемами; contract-сценарии требуют специализированных recipe.
|
||||||
|
Что с НДС
|
||||||
|
НДС-сущности и related entities в inventory присутствуют, но в текущий whitelist intents/runtime V1 они не входят как отдельный поддержанный домен.
|
||||||
|
1. Что находится в архиве
|
||||||
|
• Главный README описывает пакет как набор документов для перехода к отдельному runtime-режиму `question_mode=address_query`.
|
||||||
|
• Есть слой системной инвентаризации сущностей 1С по snapshot-корпусу 2020: entity inventory JSON-артефакты, relation/intention support sets, readable summary, run summary.
|
||||||
|
• Есть продуктовый слой: scenario matrix, query recipes, runtime contracts, integration plan, readiness matrix, question bank, acceptance suites и UI dry-run наборы.
|
||||||
|
• То есть архив сочетает два уровня: исследование структуры 1С-базы и проектирование прикладного factual-query runtime поверх этой структуры.
|
||||||
|
2. Структура пакета по смысловым блокам
|
||||||
|
Блок
|
||||||
|
Ключевые файлы
|
||||||
|
Зачем нужен
|
||||||
|
Инвентаризация 1С
|
||||||
|
entity_map_1c_2020.md, entity_inventory_*.json, run_summary.json
|
||||||
|
Показывает, какие сущности есть в snapshot-корпусе, насколько они query-suitable и какие relation patterns обнаружены.
|
||||||
|
Сценарии и intents
|
||||||
|
address_scenario_matrix.md, question_bank_v1.md
|
||||||
|
Фиксирует пользовательские вопросы, intent mapping, приоритеты и expected response types.
|
||||||
|
Recipe-слой
|
||||||
|
query_recipes_v1.md, address_runtime_contracts.md
|
||||||
|
Задает безопасный runtime-контур: intent -> filters -> recipe -> MCP -> factual result.
|
||||||
|
Интеграция и готовность
|
||||||
|
runtime_integration_plan.md, runtime_readiness_matrix_v1.md, execution_lineup_v1_2026-03-29.md
|
||||||
|
Показывает, как это встраивается и какие сценарии уже живы, а какие еще нет.
|
||||||
|
Acceptance и dry-run
|
||||||
|
curated_positive_live_suite_v1.md, data_aware_positive_acceptance_suite_v1.md, ui_dry_run_*
|
||||||
|
Нужны для live-проверки, контроля false factual и ручного прогона поддержанных вопросов.
|
||||||
|
3. Масштаб и состав inventory по базе 1С
|
||||||
|
• Источник данных: 12 monthly NDJSON snapshots за 2020 год.
|
||||||
|
• Обработано 7 039 648 строк без parse errors.
|
||||||
|
• Найдено 286 сущностей в 10 family-группах.
|
||||||
|
• Наиболее крупный пласт по строкам - INFORMATION_REGISTER (6,3 млн строк), но адресный runtime строится прежде всего на регистрах, документах, журналах и справочниках.
|
||||||
|
Family
|
||||||
|
Сущностей / строк
|
||||||
|
Приоритет
|
||||||
|
Комментарий
|
||||||
|
DOCUMENT
|
||||||
|
77 / 202 636
|
||||||
|
все P0
|
||||||
|
Главный источник document-level factual lookup.
|
||||||
|
ACCOUNTING_REGISTER
|
||||||
|
2 / 190 244
|
||||||
|
оба P0
|
||||||
|
Ключ к остаткам, хвостам и drilldown по проводкам.
|
||||||
|
NSI_CATALOG
|
||||||
|
50 / 140 050
|
||||||
|
3 P0 / 47 P1
|
||||||
|
Нужен для договоров, контрагентов и фильтровых резолверов.
|
||||||
|
DOCUMENT_JOURNAL
|
||||||
|
12 / 126 868
|
||||||
|
все P1
|
||||||
|
Быстрый индекс документов и банковских выписок.
|
||||||
|
ACCUMULATION_REGISTER
|
||||||
|
36 / 73 022
|
||||||
|
все P0
|
||||||
|
В inventory высоко видимы, но в runtime V1 почти не заведены как отдельные intents.
|
||||||
|
4. Какие домены реально прорабатываются
|
||||||
|
• Ключевой продуктовый домен - адресные factual-вопросы по взаиморасчетам и документам, а не произвольная аналитика по всей базе.
|
||||||
|
• Судя по scenario matrix, question bank и runtime contracts, текущий V1 разбит не по классическим бухгалтерским разделам, а по operational query-доменам.
|
||||||
|
Домен
|
||||||
|
Что входит
|
||||||
|
Статус
|
||||||
|
Комментарий
|
||||||
|
Контрагенты и задолженность
|
||||||
|
payables, receivables, open items by counterparty
|
||||||
|
ядро V1
|
||||||
|
Самый зрелый блок; есть curated positive cases и live-with-limits.
|
||||||
|
Счета и остатки
|
||||||
|
account balance, balance docs, turnover
|
||||||
|
частично
|
||||||
|
Структурно готово, но account-scope/materialization ограничивает стабильные non-empty ответы.
|
||||||
|
Договоры
|
||||||
|
open contracts, docs/open items by contract
|
||||||
|
не дожато
|
||||||
|
Есть в дизайне, но нужны specialized recipe и resolver path.
|
||||||
|
Документы и банковские операции
|
||||||
|
docs by counterparty, bank ops, list by type
|
||||||
|
ближе к рабочему
|
||||||
|
Именно здесь подтверждены live non-empty кейсы по counterparty family.
|
||||||
|
НДС и налоговые сущности
|
||||||
|
НДС-регистры, счета-фактуры, НДС-документы в inventory
|
||||||
|
не runtime V1
|
||||||
|
Есть в инвентаризации, но не заведены как whitelist intents.
|
||||||
|
5. Какие intents поддержаны по документам
|
||||||
|
• P0-ядро, зафиксированное в bootstrap report: `list_open_contracts`, `list_payables_counterparties`, `list_receivables_counterparties`, `account_balance_snapshot`, `open_items_by_counterparty_or_contract`.
|
||||||
|
• Как ближайшее расширение v1.1 перечислены: `list_documents_by_counterparty`, `list_documents_by_contract`, `documents_forming_balance`.
|
||||||
|
• Фактически в live runtime на момент пакета явно реализованы: `list_documents_by_counterparty`, `bank_operations_by_counterparty`, `documents_forming_balance`.
|
||||||
|
ID
|
||||||
|
Сценарий
|
||||||
|
Статус
|
||||||
|
Что мешает
|
||||||
|
AQ-P0-02
|
||||||
|
payables by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Широкие промпты пока дают sparse matches.
|
||||||
|
AQ-P0-03
|
||||||
|
receivables by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Нужны более точные period hints и anchor refinement.
|
||||||
|
AQ-P0-04
|
||||||
|
account balance snapshot
|
||||||
|
live, но с ограничениями
|
||||||
|
Строки приходят, но выпадают до materialization.
|
||||||
|
AQ-P0-05
|
||||||
|
open items by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Нужен явный counterparty anchor для стабильного non-empty.
|
||||||
|
AQ-P0-07
|
||||||
|
documents by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Есть positive cases, но якоря еще хрупкие.
|
||||||
|
AQ-P0-07B
|
||||||
|
bank ops by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Позитив подтвержден, но узкие/широкие варианты нестабильны.
|
||||||
|
AQ-P0-01/06/08
|
||||||
|
contract-related scenarios
|
||||||
|
требует спец. recipe
|
||||||
|
Не хватает contract-aware recipe и contract resolver.
|
||||||
|
AQ-P0-09
|
||||||
|
documents forming balance
|
||||||
|
live, но с ограничениями
|
||||||
|
Account family still blocked before materialization.
|
||||||
|
6. Какие сущности являются опорными
|
||||||
|
• Главная структурная опора - `AccountingRegister_Хозрасчетный_RecordType`: через него идут account, document, organization и часть business drilldown связей.
|
||||||
|
• Из документов центральны `СписаниеСРасчетногоСчета`, `ПоступлениеНаРасчетныйСчет`, их строки расшифровки платежа, а также `АктСверкиВзаиморасчетов`.
|
||||||
|
• Из справочников критичен `Catalog_ДоговорыКонтрагентов`; при этом `Catalog_Контрагенты`, `Catalog_Организации`, `Catalog_БанковскиеСчета` формально в triage отмечены как P1, но фактически обязательны для фильтров и resolver-логики.
|
||||||
|
• Document journals (`ДокументыПоставщиков`, `ДокументыПокупателей`, `БанковскиеВыписки`) играют роль быстрого индексного слоя для list/drilldown сценариев.
|
||||||
|
7. Что можно сказать про НДС
|
||||||
|
• В inventory присутствуют НДС-регистры и связанные сущности: `AccumulationRegister_НДСПредъявленный`, `AccumulationRegister_НДСЗаписиКнигиПокупок`, `AccumulationRegister_НДСЗаписиКнигиПродаж`, документы `СчетФактураПолученный`, `СчетФактураВыданный`, `СписаниеНДС`, журнал `РегламентныеДокументыНДС`.
|
||||||
|
• Однако в scenario matrix, recipe catalog и readiness matrix они не оформлены как отдельный runtime-домен текущего `address_query`.
|
||||||
|
• Поэтому корректный вывод такой: НДС уже есть в структурной инвентаризации базы, но в текущем пакете это не целевое продуктовое покрытие V1.
|
||||||
|
8. Ограничения и риски
|
||||||
|
• Часть labels и entity names в исходном экспорте испорчена cp1251/utf8 mojibake; потребуется финальный decoding/cleanup перед production binding.
|
||||||
|
• Без business resolvers нельзя надежно переходить от имени контрагента, договора или счета к ID.
|
||||||
|
• Free-form query builder специально запрещен; доступ предполагается только через whitelist recipe.
|
||||||
|
• Compound factual вопросы пока только детектируются, но не исполняются как multi-intent decomposition.
|
||||||
|
• Account-семейство пока не дает стабильный поток до materialization, хотя сырье на входе уже есть.
|
||||||
|
9. Выводы по текущему состоянию
|
||||||
|
• Архив уже дает хорошую картину по 1С-сущностям и будущему runtime.
|
||||||
|
• Сейчас реально прорабатывается не вся база и не весь бухгалтерский домен, а конкретный address-query слой: контрагенты, задолженность, договоры, документы, банковские операции, остатки/расшифровка по счетам.
|
||||||
|
• Самый зрелый operational блок - counterparty/document lookup.
|
||||||
|
• Contract-specific и account-specific сценарии структурно готовы, но требуют еще одного цикла реализации и настройки.
|
||||||
|
• НДС виден в inventory и может стать следующим отдельным доменом, но в данном архиве это пока не активное runtime-покрытие.
|
||||||
|
10. Практический next step
|
||||||
|
• Собрать отдельную матрицу: “что есть в inventory” vs “что заведено в intents/recipes” vs “что уже реально работает live”.
|
||||||
|
• Развести домены на 3 слоя: текущий рабочий V1, design-only backlog и visible-inventory but not productized.
|
||||||
|
• Отдельно сделать shortlist по НДС/налоговым сущностям, если следующая цель - расширить покрытие за пределы address_query.
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="jpeg" ContentType="image/jpeg"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/stylesWithEffects.xml" ContentType="application/vnd.ms-word.stylesWithEffects+xml"/><Override PartName="/word/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/word/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/></Types>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" Target="docProps/thumbnail.jpeg"/></Relationships>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps" Target="itemProps1.xml"/></Relationships>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" xmlns="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" SelectedStyle="/APA.XSL" StyleName="APA"/>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<ds:datastoreItem xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml" ds:itemID="{EF278816-EC6F-A645-907D-7F25AECB1D4A}">
|
||||||
|
<ds:schemaRefs>
|
||||||
|
<ds:schemaRef ds:uri="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"/>
|
||||||
|
</ds:schemaRefs>
|
||||||
|
</ds:datastoreItem>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||||
|
<Template>Normal.dotm</Template>
|
||||||
|
<TotalTime>0</TotalTime>
|
||||||
|
<Pages>1</Pages>
|
||||||
|
<Words>0</Words>
|
||||||
|
<Characters>0</Characters>
|
||||||
|
<Application>Microsoft Macintosh Word</Application>
|
||||||
|
<DocSecurity>0</DocSecurity>
|
||||||
|
<Lines>0</Lines>
|
||||||
|
<Paragraphs>0</Paragraphs>
|
||||||
|
<ScaleCrop>false</ScaleCrop>
|
||||||
|
<HeadingPairs>
|
||||||
|
<vt:vector size="2" baseType="variant">
|
||||||
|
<vt:variant>
|
||||||
|
<vt:lpstr>Title</vt:lpstr>
|
||||||
|
</vt:variant>
|
||||||
|
<vt:variant>
|
||||||
|
<vt:i4>1</vt:i4>
|
||||||
|
</vt:variant>
|
||||||
|
</vt:vector>
|
||||||
|
</HeadingPairs>
|
||||||
|
<TitlesOfParts>
|
||||||
|
<vt:vector size="1" baseType="lpstr">
|
||||||
|
<vt:lpstr/>
|
||||||
|
</vt:vector>
|
||||||
|
</TitlesOfParts>
|
||||||
|
<Manager/>
|
||||||
|
<Company/>
|
||||||
|
<LinksUpToDate>false</LinksUpToDate>
|
||||||
|
<CharactersWithSpaces>0</CharactersWithSpaces>
|
||||||
|
<SharedDoc>false</SharedDoc>
|
||||||
|
<HyperlinkBase/>
|
||||||
|
<HyperlinksChanged>false</HyperlinksChanged>
|
||||||
|
<AppVersion>14.0000</AppVersion>
|
||||||
|
</Properties>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title/><dc:subject/><dc:creator>python-docx</dc:creator><cp:keywords/><dc:description>generated by python-docx</dc:description><cp:lastModifiedBy/><cp:revision>1</cp:revision><dcterms:created xsi:type="dcterms:W3CDTF">2013-12-23T23:15:00Z</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">2013-12-23T23:15:00Z</dcterms:modified><cp:category/></cp:coreProperties>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects" Target="stylesWithEffects.xml"/><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" Target="webSettings.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<w:fonts xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" mc:Ignorable="w14">
|
||||||
|
<w:font w:name="Symbol">
|
||||||
|
<w:panose1 w:val="00000000000000000000"/>
|
||||||
|
<w:charset w:val="02"/>
|
||||||
|
<w:family w:val="auto"/>
|
||||||
|
<w:pitch w:val="variable"/>
|
||||||
|
<w:sig w:usb0="00000000" w:usb1="10000000" w:usb2="00000000" w:usb3="00000000" w:csb0="80000000" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="Times New Roman">
|
||||||
|
<w:panose1 w:val="02020603050405020304"/>
|
||||||
|
<w:charset w:val="00"/>
|
||||||
|
<w:family w:val="auto"/>
|
||||||
|
<w:pitch w:val="variable"/>
|
||||||
|
<w:sig w:usb0="E0002AFF" w:usb1="C0007841" w:usb2="00000009" w:usb3="00000000" w:csb0="000001FF" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="Cambria">
|
||||||
|
<w:panose1 w:val="02040503050406030204"/>
|
||||||
|
<w:charset w:val="00"/>
|
||||||
|
<w:family w:val="auto"/>
|
||||||
|
<w:pitch w:val="variable"/>
|
||||||
|
<w:sig w:usb0="E00002FF" w:usb1="400004FF" w:usb2="00000000" w:usb3="00000000" w:csb0="0000019F" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="MS 明朝">
|
||||||
|
<w:panose1 w:val="00000000000000000000"/>
|
||||||
|
<w:charset w:val="80"/>
|
||||||
|
<w:family w:val="roman"/>
|
||||||
|
<w:notTrueType/>
|
||||||
|
<w:pitch w:val="fixed"/>
|
||||||
|
<w:sig w:usb0="00000001" w:usb1="08070000" w:usb2="00000010" w:usb3="00000000" w:csb0="00020000" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="Calibri">
|
||||||
|
<w:panose1 w:val="020F0502020204030204"/>
|
||||||
|
<w:charset w:val="00"/>
|
||||||
|
<w:family w:val="auto"/>
|
||||||
|
<w:pitch w:val="variable"/>
|
||||||
|
<w:sig w:usb0="E10002FF" w:usb1="4000ACFF" w:usb2="00000009" w:usb3="00000000" w:csb0="0000019F" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="MS ゴシック">
|
||||||
|
<w:panose1 w:val="00000000000000000000"/>
|
||||||
|
<w:charset w:val="80"/>
|
||||||
|
<w:family w:val="modern"/>
|
||||||
|
<w:notTrueType/>
|
||||||
|
<w:pitch w:val="fixed"/>
|
||||||
|
<w:sig w:usb0="00000001" w:usb1="08070000" w:usb2="00000010" w:usb3="00000000" w:csb0="00020000" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="Courier">
|
||||||
|
<w:panose1 w:val="02000500000000000000"/>
|
||||||
|
<w:charset w:val="00"/>
|
||||||
|
<w:family w:val="auto"/>
|
||||||
|
<w:pitch w:val="variable"/>
|
||||||
|
<w:sig w:usb0="00000003" w:usb1="00000000" w:usb2="00000000" w:usb3="00000000" w:csb0="00000001" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
<w:font w:name="Arial">
|
||||||
|
<w:panose1 w:val="020B0604020202020204"/>
|
||||||
|
<w:charset w:val="00"/>
|
||||||
|
<w:family w:val="auto"/>
|
||||||
|
<w:pitch w:val="variable"/>
|
||||||
|
<w:sig w:usb0="E0002AFF" w:usb1="C0007843" w:usb2="00000009" w:usb3="00000000" w:csb0="000001FF" w:csb1="00000000"/>
|
||||||
|
</w:font>
|
||||||
|
</w:fonts>
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<w:settings xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:sl="http://schemas.openxmlformats.org/schemaLibrary/2006/main" mc:Ignorable="w14"><w:zoom w:val="bestFit"/><w:proofState w:spelling="clean" w:grammar="clean"/><w:defaultTabStop w:val="720"/><w:characterSpacingControl w:val="doNotCompress"/><w:savePreviewPicture/><w:compat><w:useFELayout/><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="14"/><w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="doNotFlipMirrorIndents" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/></w:compat><w:rsids><w:rsidRoot w:val="00B47730"/><w:rsid w:val="00034616"/><w:rsid w:val="0006063C"/><w:rsid w:val="0015074B"/><w:rsid w:val="0029639D"/><w:rsid w:val="00326F90"/><w:rsid w:val="00AA1D8D"/><w:rsid w:val="00B47730"/><w:rsid w:val="00CB0664"/><w:rsid w:val="00FC693F"/></w:rsids><m:mathPr><m:mathFont m:val="Cambria Math"/><m:brkBin m:val="before"/><m:brkBinSub m:val="--"/><m:smallFrac m:val="0"/><m:dispDef/><m:lMargin m:val="0"/><m:rMargin m:val="0"/><m:defJc m:val="centerGroup"/><m:wrapIndent m:val="1440"/><m:intLim m:val="subSup"/><m:naryLim m:val="undOvr"/></m:mathPr><w:themeFontLang w:val="en-US" w:eastAsia="ja-JP"/><w:clrSchemeMapping w:bg1="light1" w:t1="dark1" w:bg2="light2" w:t2="dark2" w:accent1="accent1" w:accent2="accent2" w:accent3="accent3" w:accent4="accent4" w:accent5="accent5" w:accent6="accent6" w:hyperlink="hyperlink" w:followedHyperlink="followedHyperlink"/><w:doNotAutoCompressPictures/><w:shapeDefaults><o:shapedefaults v:ext="edit" spidmax="1027"/><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout></w:shapeDefaults><w:decimalSymbol w:val="."/><w:listSeparator w:val=","/><w14:docId w14:val="24062061"/><w14:defaultImageDpi w14:val="300"/></w:settings>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,318 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme">
|
||||||
|
<a:themeElements>
|
||||||
|
<a:clrScheme name="Office">
|
||||||
|
<a:dk1>
|
||||||
|
<a:sysClr val="windowText" lastClr="000000"/>
|
||||||
|
</a:dk1>
|
||||||
|
<a:lt1>
|
||||||
|
<a:sysClr val="window" lastClr="FFFFFF"/>
|
||||||
|
</a:lt1>
|
||||||
|
<a:dk2>
|
||||||
|
<a:srgbClr val="1F497D"/>
|
||||||
|
</a:dk2>
|
||||||
|
<a:lt2>
|
||||||
|
<a:srgbClr val="EEECE1"/>
|
||||||
|
</a:lt2>
|
||||||
|
<a:accent1>
|
||||||
|
<a:srgbClr val="4F81BD"/>
|
||||||
|
</a:accent1>
|
||||||
|
<a:accent2>
|
||||||
|
<a:srgbClr val="C0504D"/>
|
||||||
|
</a:accent2>
|
||||||
|
<a:accent3>
|
||||||
|
<a:srgbClr val="9BBB59"/>
|
||||||
|
</a:accent3>
|
||||||
|
<a:accent4>
|
||||||
|
<a:srgbClr val="8064A2"/>
|
||||||
|
</a:accent4>
|
||||||
|
<a:accent5>
|
||||||
|
<a:srgbClr val="4BACC6"/>
|
||||||
|
</a:accent5>
|
||||||
|
<a:accent6>
|
||||||
|
<a:srgbClr val="F79646"/>
|
||||||
|
</a:accent6>
|
||||||
|
<a:hlink>
|
||||||
|
<a:srgbClr val="0000FF"/>
|
||||||
|
</a:hlink>
|
||||||
|
<a:folHlink>
|
||||||
|
<a:srgbClr val="800080"/>
|
||||||
|
</a:folHlink>
|
||||||
|
</a:clrScheme>
|
||||||
|
<a:fontScheme name="Office">
|
||||||
|
<a:majorFont>
|
||||||
|
<a:latin typeface="Calibri"/>
|
||||||
|
<a:ea typeface=""/>
|
||||||
|
<a:cs typeface=""/>
|
||||||
|
<a:font script="Jpan" typeface="MS ゴシック"/>
|
||||||
|
<a:font script="Hang" typeface="맑은 고딕"/>
|
||||||
|
<a:font script="Hans" typeface="宋体"/>
|
||||||
|
<a:font script="Hant" typeface="新細明體"/>
|
||||||
|
<a:font script="Arab" typeface="Times New Roman"/>
|
||||||
|
<a:font script="Hebr" typeface="Times New Roman"/>
|
||||||
|
<a:font script="Thai" typeface="Angsana New"/>
|
||||||
|
<a:font script="Ethi" typeface="Nyala"/>
|
||||||
|
<a:font script="Beng" typeface="Vrinda"/>
|
||||||
|
<a:font script="Gujr" typeface="Shruti"/>
|
||||||
|
<a:font script="Khmr" typeface="MoolBoran"/>
|
||||||
|
<a:font script="Knda" typeface="Tunga"/>
|
||||||
|
<a:font script="Guru" typeface="Raavi"/>
|
||||||
|
<a:font script="Cans" typeface="Euphemia"/>
|
||||||
|
<a:font script="Cher" typeface="Plantagenet Cherokee"/>
|
||||||
|
<a:font script="Yiii" typeface="Microsoft Yi Baiti"/>
|
||||||
|
<a:font script="Tibt" typeface="Microsoft Himalaya"/>
|
||||||
|
<a:font script="Thaa" typeface="MV Boli"/>
|
||||||
|
<a:font script="Deva" typeface="Mangal"/>
|
||||||
|
<a:font script="Telu" typeface="Gautami"/>
|
||||||
|
<a:font script="Taml" typeface="Latha"/>
|
||||||
|
<a:font script="Syrc" typeface="Estrangelo Edessa"/>
|
||||||
|
<a:font script="Orya" typeface="Kalinga"/>
|
||||||
|
<a:font script="Mlym" typeface="Kartika"/>
|
||||||
|
<a:font script="Laoo" typeface="DokChampa"/>
|
||||||
|
<a:font script="Sinh" typeface="Iskoola Pota"/>
|
||||||
|
<a:font script="Mong" typeface="Mongolian Baiti"/>
|
||||||
|
<a:font script="Viet" typeface="Times New Roman"/>
|
||||||
|
<a:font script="Uigh" typeface="Microsoft Uighur"/>
|
||||||
|
<a:font script="Geor" typeface="Sylfaen"/>
|
||||||
|
</a:majorFont>
|
||||||
|
<a:minorFont>
|
||||||
|
<a:latin typeface="Cambria"/>
|
||||||
|
<a:ea typeface=""/>
|
||||||
|
<a:cs typeface=""/>
|
||||||
|
<a:font script="Jpan" typeface="MS 明朝"/>
|
||||||
|
<a:font script="Hang" typeface="맑은 고딕"/>
|
||||||
|
<a:font script="Hans" typeface="宋体"/>
|
||||||
|
<a:font script="Hant" typeface="新細明體"/>
|
||||||
|
<a:font script="Arab" typeface="Arial"/>
|
||||||
|
<a:font script="Hebr" typeface="Arial"/>
|
||||||
|
<a:font script="Thai" typeface="Cordia New"/>
|
||||||
|
<a:font script="Ethi" typeface="Nyala"/>
|
||||||
|
<a:font script="Beng" typeface="Vrinda"/>
|
||||||
|
<a:font script="Gujr" typeface="Shruti"/>
|
||||||
|
<a:font script="Khmr" typeface="DaunPenh"/>
|
||||||
|
<a:font script="Knda" typeface="Tunga"/>
|
||||||
|
<a:font script="Guru" typeface="Raavi"/>
|
||||||
|
<a:font script="Cans" typeface="Euphemia"/>
|
||||||
|
<a:font script="Cher" typeface="Plantagenet Cherokee"/>
|
||||||
|
<a:font script="Yiii" typeface="Microsoft Yi Baiti"/>
|
||||||
|
<a:font script="Tibt" typeface="Microsoft Himalaya"/>
|
||||||
|
<a:font script="Thaa" typeface="MV Boli"/>
|
||||||
|
<a:font script="Deva" typeface="Mangal"/>
|
||||||
|
<a:font script="Telu" typeface="Gautami"/>
|
||||||
|
<a:font script="Taml" typeface="Latha"/>
|
||||||
|
<a:font script="Syrc" typeface="Estrangelo Edessa"/>
|
||||||
|
<a:font script="Orya" typeface="Kalinga"/>
|
||||||
|
<a:font script="Mlym" typeface="Kartika"/>
|
||||||
|
<a:font script="Laoo" typeface="DokChampa"/>
|
||||||
|
<a:font script="Sinh" typeface="Iskoola Pota"/>
|
||||||
|
<a:font script="Mong" typeface="Mongolian Baiti"/>
|
||||||
|
<a:font script="Viet" typeface="Arial"/>
|
||||||
|
<a:font script="Uigh" typeface="Microsoft Uighur"/>
|
||||||
|
<a:font script="Geor" typeface="Sylfaen"/>
|
||||||
|
</a:minorFont>
|
||||||
|
</a:fontScheme>
|
||||||
|
<a:fmtScheme name="Office">
|
||||||
|
<a:fillStyleLst>
|
||||||
|
<a:solidFill>
|
||||||
|
<a:schemeClr val="phClr"/>
|
||||||
|
</a:solidFill>
|
||||||
|
<a:gradFill rotWithShape="1">
|
||||||
|
<a:gsLst>
|
||||||
|
<a:gs pos="0">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="50000"/>
|
||||||
|
<a:satMod val="300000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
<a:gs pos="35000">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="37000"/>
|
||||||
|
<a:satMod val="300000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
<a:gs pos="100000">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="15000"/>
|
||||||
|
<a:satMod val="350000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
</a:gsLst>
|
||||||
|
<a:lin ang="16200000" scaled="1"/>
|
||||||
|
</a:gradFill>
|
||||||
|
<a:gradFill rotWithShape="1">
|
||||||
|
<a:gsLst>
|
||||||
|
<a:gs pos="0">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="100000"/>
|
||||||
|
<a:shade val="100000"/>
|
||||||
|
<a:satMod val="130000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
<a:gs pos="100000">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="50000"/>
|
||||||
|
<a:shade val="100000"/>
|
||||||
|
<a:satMod val="350000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
</a:gsLst>
|
||||||
|
<a:lin ang="16200000" scaled="0"/>
|
||||||
|
</a:gradFill>
|
||||||
|
</a:fillStyleLst>
|
||||||
|
<a:lnStyleLst>
|
||||||
|
<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr">
|
||||||
|
<a:solidFill>
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:shade val="95000"/>
|
||||||
|
<a:satMod val="105000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:solidFill>
|
||||||
|
<a:prstDash val="solid"/>
|
||||||
|
</a:ln>
|
||||||
|
<a:ln w="25400" cap="flat" cmpd="sng" algn="ctr">
|
||||||
|
<a:solidFill>
|
||||||
|
<a:schemeClr val="phClr"/>
|
||||||
|
</a:solidFill>
|
||||||
|
<a:prstDash val="solid"/>
|
||||||
|
</a:ln>
|
||||||
|
<a:ln w="38100" cap="flat" cmpd="sng" algn="ctr">
|
||||||
|
<a:solidFill>
|
||||||
|
<a:schemeClr val="phClr"/>
|
||||||
|
</a:solidFill>
|
||||||
|
<a:prstDash val="solid"/>
|
||||||
|
</a:ln>
|
||||||
|
</a:lnStyleLst>
|
||||||
|
<a:effectStyleLst>
|
||||||
|
<a:effectStyle>
|
||||||
|
<a:effectLst>
|
||||||
|
<a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0">
|
||||||
|
<a:srgbClr val="000000">
|
||||||
|
<a:alpha val="38000"/>
|
||||||
|
</a:srgbClr>
|
||||||
|
</a:outerShdw>
|
||||||
|
</a:effectLst>
|
||||||
|
</a:effectStyle>
|
||||||
|
<a:effectStyle>
|
||||||
|
<a:effectLst>
|
||||||
|
<a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0">
|
||||||
|
<a:srgbClr val="000000">
|
||||||
|
<a:alpha val="35000"/>
|
||||||
|
</a:srgbClr>
|
||||||
|
</a:outerShdw>
|
||||||
|
</a:effectLst>
|
||||||
|
</a:effectStyle>
|
||||||
|
<a:effectStyle>
|
||||||
|
<a:effectLst>
|
||||||
|
<a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0">
|
||||||
|
<a:srgbClr val="000000">
|
||||||
|
<a:alpha val="35000"/>
|
||||||
|
</a:srgbClr>
|
||||||
|
</a:outerShdw>
|
||||||
|
</a:effectLst>
|
||||||
|
<a:scene3d>
|
||||||
|
<a:camera prst="orthographicFront">
|
||||||
|
<a:rot lat="0" lon="0" rev="0"/>
|
||||||
|
</a:camera>
|
||||||
|
<a:lightRig rig="threePt" dir="t">
|
||||||
|
<a:rot lat="0" lon="0" rev="1200000"/>
|
||||||
|
</a:lightRig>
|
||||||
|
</a:scene3d>
|
||||||
|
<a:sp3d>
|
||||||
|
<a:bevelT w="63500" h="25400"/>
|
||||||
|
</a:sp3d>
|
||||||
|
</a:effectStyle>
|
||||||
|
</a:effectStyleLst>
|
||||||
|
<a:bgFillStyleLst>
|
||||||
|
<a:solidFill>
|
||||||
|
<a:schemeClr val="phClr"/>
|
||||||
|
</a:solidFill>
|
||||||
|
<a:gradFill rotWithShape="1">
|
||||||
|
<a:gsLst>
|
||||||
|
<a:gs pos="0">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="40000"/>
|
||||||
|
<a:satMod val="350000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
<a:gs pos="40000">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="45000"/>
|
||||||
|
<a:shade val="99000"/>
|
||||||
|
<a:satMod val="350000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
<a:gs pos="100000">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:shade val="20000"/>
|
||||||
|
<a:satMod val="255000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
</a:gsLst>
|
||||||
|
<a:path path="circle">
|
||||||
|
<a:fillToRect l="50000" t="-80000" r="50000" b="180000"/>
|
||||||
|
</a:path>
|
||||||
|
</a:gradFill>
|
||||||
|
<a:gradFill rotWithShape="1">
|
||||||
|
<a:gsLst>
|
||||||
|
<a:gs pos="0">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:tint val="80000"/>
|
||||||
|
<a:satMod val="300000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
<a:gs pos="100000">
|
||||||
|
<a:schemeClr val="phClr">
|
||||||
|
<a:shade val="30000"/>
|
||||||
|
<a:satMod val="200000"/>
|
||||||
|
</a:schemeClr>
|
||||||
|
</a:gs>
|
||||||
|
</a:gsLst>
|
||||||
|
<a:path path="circle">
|
||||||
|
<a:fillToRect l="50000" t="50000" r="50000" b="50000"/>
|
||||||
|
</a:path>
|
||||||
|
</a:gradFill>
|
||||||
|
</a:bgFillStyleLst>
|
||||||
|
</a:fmtScheme>
|
||||||
|
</a:themeElements>
|
||||||
|
<a:objectDefaults>
|
||||||
|
<a:spDef>
|
||||||
|
<a:spPr/>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:style>
|
||||||
|
<a:lnRef idx="1">
|
||||||
|
<a:schemeClr val="accent1"/>
|
||||||
|
</a:lnRef>
|
||||||
|
<a:fillRef idx="3">
|
||||||
|
<a:schemeClr val="accent1"/>
|
||||||
|
</a:fillRef>
|
||||||
|
<a:effectRef idx="2">
|
||||||
|
<a:schemeClr val="accent1"/>
|
||||||
|
</a:effectRef>
|
||||||
|
<a:fontRef idx="minor">
|
||||||
|
<a:schemeClr val="lt1"/>
|
||||||
|
</a:fontRef>
|
||||||
|
</a:style>
|
||||||
|
</a:spDef>
|
||||||
|
<a:lnDef>
|
||||||
|
<a:spPr/>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:style>
|
||||||
|
<a:lnRef idx="2">
|
||||||
|
<a:schemeClr val="accent1"/>
|
||||||
|
</a:lnRef>
|
||||||
|
<a:fillRef idx="0">
|
||||||
|
<a:schemeClr val="accent1"/>
|
||||||
|
</a:fillRef>
|
||||||
|
<a:effectRef idx="1">
|
||||||
|
<a:schemeClr val="accent1"/>
|
||||||
|
</a:effectRef>
|
||||||
|
<a:fontRef idx="minor">
|
||||||
|
<a:schemeClr val="tx1"/>
|
||||||
|
</a:fontRef>
|
||||||
|
</a:style>
|
||||||
|
</a:lnDef>
|
||||||
|
</a:objectDefaults>
|
||||||
|
<a:extraClrSchemeLst/>
|
||||||
|
</a:theme>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
|
||||||
|
<w:webSettings xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" mc:Ignorable="w14">
|
||||||
|
<w:allowPNG/>
|
||||||
|
<w:doNotSaveAsSingleFile/>
|
||||||
|
</w:webSettings>
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,157 @@
|
||||||
|
|
||||||
|
Отчет по анализу архива address_query
|
||||||
|
Инвентаризация разметки 1С, покрытие доменов и статус runtime-сценариев
|
||||||
|
Источник: архив address_query.zip | Дата документов внутри архива: 2026-03-29
|
||||||
|
Главный вывод
|
||||||
|
Архив посвящен не общему налоговому/НДС-контуру, а отдельному runtime-режиму address_query для factual lookup по взаиморасчетам, договорам, документам, банковским операциям и остаткам по счетам.
|
||||||
|
Масштаб inventory
|
||||||
|
Обработано 7 039 648 строк из 12 monthly snapshot-файлов за 2020 год, parse errors = 0, найдено 286 уникальных сущностей в 10 family-группах.
|
||||||
|
Что реально покрыто сейчас
|
||||||
|
На уровне V1/M2.3c подтверждены прежде всего сценарии по контрагентам и документам; account-сценарии видны, но еще ограничены materialization/account-scope проблемами; contract-сценарии требуют специализированных recipe.
|
||||||
|
Что с НДС
|
||||||
|
НДС-сущности и related entities в inventory присутствуют, но в текущий whitelist intents/runtime V1 они не входят как отдельный поддержанный домен.
|
||||||
|
1. Что находится в архиве
|
||||||
|
• Главный README описывает пакет как набор документов для перехода к отдельному runtime-режиму `question_mode=address_query`.
|
||||||
|
• Есть слой системной инвентаризации сущностей 1С по snapshot-корпусу 2020: entity inventory JSON-артефакты, relation/intention support sets, readable summary, run summary.
|
||||||
|
• Есть продуктовый слой: scenario matrix, query recipes, runtime contracts, integration plan, readiness matrix, question bank, acceptance suites и UI dry-run наборы.
|
||||||
|
• То есть архив сочетает два уровня: исследование структуры 1С-базы и проектирование прикладного factual-query runtime поверх этой структуры.
|
||||||
|
2. Структура пакета по смысловым блокам
|
||||||
|
Блок
|
||||||
|
Ключевые файлы
|
||||||
|
Зачем нужен
|
||||||
|
Инвентаризация 1С
|
||||||
|
entity_map_1c_2020.md, entity_inventory_*.json, run_summary.json
|
||||||
|
Показывает, какие сущности есть в snapshot-корпусе, насколько они query-suitable и какие relation patterns обнаружены.
|
||||||
|
Сценарии и intents
|
||||||
|
address_scenario_matrix.md, question_bank_v1.md
|
||||||
|
Фиксирует пользовательские вопросы, intent mapping, приоритеты и expected response types.
|
||||||
|
Recipe-слой
|
||||||
|
query_recipes_v1.md, address_runtime_contracts.md
|
||||||
|
Задает безопасный runtime-контур: intent -> filters -> recipe -> MCP -> factual result.
|
||||||
|
Интеграция и готовность
|
||||||
|
runtime_integration_plan.md, runtime_readiness_matrix_v1.md, execution_lineup_v1_2026-03-29.md
|
||||||
|
Показывает, как это встраивается и какие сценарии уже живы, а какие еще нет.
|
||||||
|
Acceptance и dry-run
|
||||||
|
curated_positive_live_suite_v1.md, data_aware_positive_acceptance_suite_v1.md, ui_dry_run_*
|
||||||
|
Нужны для live-проверки, контроля false factual и ручного прогона поддержанных вопросов.
|
||||||
|
3. Масштаб и состав inventory по базе 1С
|
||||||
|
• Источник данных: 12 monthly NDJSON snapshots за 2020 год.
|
||||||
|
• Обработано 7 039 648 строк без parse errors.
|
||||||
|
• Найдено 286 сущностей в 10 family-группах.
|
||||||
|
• Наиболее крупный пласт по строкам - INFORMATION_REGISTER (6,3 млн строк), но адресный runtime строится прежде всего на регистрах, документах, журналах и справочниках.
|
||||||
|
Family
|
||||||
|
Сущностей / строк
|
||||||
|
Приоритет
|
||||||
|
Комментарий
|
||||||
|
DOCUMENT
|
||||||
|
77 / 202 636
|
||||||
|
все P0
|
||||||
|
Главный источник document-level factual lookup.
|
||||||
|
ACCOUNTING_REGISTER
|
||||||
|
2 / 190 244
|
||||||
|
оба P0
|
||||||
|
Ключ к остаткам, хвостам и drilldown по проводкам.
|
||||||
|
NSI_CATALOG
|
||||||
|
50 / 140 050
|
||||||
|
3 P0 / 47 P1
|
||||||
|
Нужен для договоров, контрагентов и фильтровых резолверов.
|
||||||
|
DOCUMENT_JOURNAL
|
||||||
|
12 / 126 868
|
||||||
|
все P1
|
||||||
|
Быстрый индекс документов и банковских выписок.
|
||||||
|
ACCUMULATION_REGISTER
|
||||||
|
36 / 73 022
|
||||||
|
все P0
|
||||||
|
В inventory высоко видимы, но в runtime V1 почти не заведены как отдельные intents.
|
||||||
|
4. Какие домены реально прорабатываются
|
||||||
|
• Ключевой продуктовый домен - адресные factual-вопросы по взаиморасчетам и документам, а не произвольная аналитика по всей базе.
|
||||||
|
• Судя по scenario matrix, question bank и runtime contracts, текущий V1 разбит не по классическим бухгалтерским разделам, а по operational query-доменам.
|
||||||
|
Домен
|
||||||
|
Что входит
|
||||||
|
Статус
|
||||||
|
Комментарий
|
||||||
|
Контрагенты и задолженность
|
||||||
|
payables, receivables, open items by counterparty
|
||||||
|
ядро V1
|
||||||
|
Самый зрелый блок; есть curated positive cases и live-with-limits.
|
||||||
|
Счета и остатки
|
||||||
|
account balance, balance docs, turnover
|
||||||
|
частично
|
||||||
|
Структурно готово, но account-scope/materialization ограничивает стабильные non-empty ответы.
|
||||||
|
Договоры
|
||||||
|
open contracts, docs/open items by contract
|
||||||
|
не дожато
|
||||||
|
Есть в дизайне, но нужны specialized recipe и resolver path.
|
||||||
|
Документы и банковские операции
|
||||||
|
docs by counterparty, bank ops, list by type
|
||||||
|
ближе к рабочему
|
||||||
|
Именно здесь подтверждены live non-empty кейсы по counterparty family.
|
||||||
|
НДС и налоговые сущности
|
||||||
|
НДС-регистры, счета-фактуры, НДС-документы в inventory
|
||||||
|
не runtime V1
|
||||||
|
Есть в инвентаризации, но не заведены как whitelist intents.
|
||||||
|
5. Какие intents поддержаны по документам
|
||||||
|
• P0-ядро, зафиксированное в bootstrap report: `list_open_contracts`, `list_payables_counterparties`, `list_receivables_counterparties`, `account_balance_snapshot`, `open_items_by_counterparty_or_contract`.
|
||||||
|
• Как ближайшее расширение v1.1 перечислены: `list_documents_by_counterparty`, `list_documents_by_contract`, `documents_forming_balance`.
|
||||||
|
• Фактически в live runtime на момент пакета явно реализованы: `list_documents_by_counterparty`, `bank_operations_by_counterparty`, `documents_forming_balance`.
|
||||||
|
ID
|
||||||
|
Сценарий
|
||||||
|
Статус
|
||||||
|
Что мешает
|
||||||
|
AQ-P0-02
|
||||||
|
payables by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Широкие промпты пока дают sparse matches.
|
||||||
|
AQ-P0-03
|
||||||
|
receivables by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Нужны более точные period hints и anchor refinement.
|
||||||
|
AQ-P0-04
|
||||||
|
account balance snapshot
|
||||||
|
live, но с ограничениями
|
||||||
|
Строки приходят, но выпадают до materialization.
|
||||||
|
AQ-P0-05
|
||||||
|
open items by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Нужен явный counterparty anchor для стабильного non-empty.
|
||||||
|
AQ-P0-07
|
||||||
|
documents by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Есть positive cases, но якоря еще хрупкие.
|
||||||
|
AQ-P0-07B
|
||||||
|
bank ops by counterparty
|
||||||
|
live, но с ограничениями
|
||||||
|
Позитив подтвержден, но узкие/широкие варианты нестабильны.
|
||||||
|
AQ-P0-01/06/08
|
||||||
|
contract-related scenarios
|
||||||
|
требует спец. recipe
|
||||||
|
Не хватает contract-aware recipe и contract resolver.
|
||||||
|
AQ-P0-09
|
||||||
|
documents forming balance
|
||||||
|
live, но с ограничениями
|
||||||
|
Account family still blocked before materialization.
|
||||||
|
6. Какие сущности являются опорными
|
||||||
|
• Главная структурная опора - `AccountingRegister_Хозрасчетный_RecordType`: через него идут account, document, organization и часть business drilldown связей.
|
||||||
|
• Из документов центральны `СписаниеСРасчетногоСчета`, `ПоступлениеНаРасчетныйСчет`, их строки расшифровки платежа, а также `АктСверкиВзаиморасчетов`.
|
||||||
|
• Из справочников критичен `Catalog_ДоговорыКонтрагентов`; при этом `Catalog_Контрагенты`, `Catalog_Организации`, `Catalog_БанковскиеСчета` формально в triage отмечены как P1, но фактически обязательны для фильтров и resolver-логики.
|
||||||
|
• Document journals (`ДокументыПоставщиков`, `ДокументыПокупателей`, `БанковскиеВыписки`) играют роль быстрого индексного слоя для list/drilldown сценариев.
|
||||||
|
7. Что можно сказать про НДС
|
||||||
|
• В inventory присутствуют НДС-регистры и связанные сущности: `AccumulationRegister_НДСПредъявленный`, `AccumulationRegister_НДСЗаписиКнигиПокупок`, `AccumulationRegister_НДСЗаписиКнигиПродаж`, документы `СчетФактураПолученный`, `СчетФактураВыданный`, `СписаниеНДС`, журнал `РегламентныеДокументыНДС`.
|
||||||
|
• Однако в scenario matrix, recipe catalog и readiness matrix они не оформлены как отдельный runtime-домен текущего `address_query`.
|
||||||
|
• Поэтому корректный вывод такой: НДС уже есть в структурной инвентаризации базы, но в текущем пакете это не целевое продуктовое покрытие V1.
|
||||||
|
8. Ограничения и риски
|
||||||
|
• Часть labels и entity names в исходном экспорте испорчена cp1251/utf8 mojibake; потребуется финальный decoding/cleanup перед production binding.
|
||||||
|
• Без business resolvers нельзя надежно переходить от имени контрагента, договора или счета к ID.
|
||||||
|
• Free-form query builder специально запрещен; доступ предполагается только через whitelist recipe.
|
||||||
|
• Compound factual вопросы пока только детектируются, но не исполняются как multi-intent decomposition.
|
||||||
|
• Account-семейство пока не дает стабильный поток до materialization, хотя сырье на входе уже есть.
|
||||||
|
9. Выводы по текущему состоянию
|
||||||
|
• Архив уже дает хорошую картину по 1С-сущностям и будущему runtime.
|
||||||
|
• Сейчас реально прорабатывается не вся база и не весь бухгалтерский домен, а конкретный address-query слой: контрагенты, задолженность, договоры, документы, банковские операции, остатки/расшифровка по счетам.
|
||||||
|
• Самый зрелый operational блок - counterparty/document lookup.
|
||||||
|
• Contract-specific и account-specific сценарии структурно готовы, но требуют еще одного цикла реализации и настройки.
|
||||||
|
• НДС виден в inventory и может стать следующим отдельным доменом, но в данном архиве это пока не активное runtime-покрытие.
|
||||||
|
10. Практический next step
|
||||||
|
• Собрать отдельную матрицу: “что есть в inventory” vs “что заведено в intents/recipes” vs “что уже реально работает live”.
|
||||||
|
• Развести домены на 3 слоя: текущий рабочий V1, design-only backlog и visible-inventory but not productized.
|
||||||
|
• Отдельно сделать shortlist по НДС/налоговым сущностям, если следующая цель - расширить покрытие за пределы address_query.
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,42 @@
|
||||||
|
# ADDRESS Query Production Pack V2
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Этот пакет расширяет исходный `address_query` архив до production-oriented слоя:
|
||||||
|
- фиксирует полный доменный контур;
|
||||||
|
- раскладывает покрытие по intent/recipe/filter;
|
||||||
|
- добавляет массовый банк пользовательских формулировок в разных диалектах;
|
||||||
|
- задает acceptance-пакет для live проверки и дальнейшей доработки runtime.
|
||||||
|
|
||||||
|
## Что внутри
|
||||||
|
|
||||||
|
- `prod_domain_expansion_report.md` — итоговый аналитический отчет и стратегия доведения до prod.
|
||||||
|
- `address_domain_matrix_v2.md` — целевая матрица доменов/интентов/рецептов.
|
||||||
|
- `query_paraphrase_bank_v2.md` — расширенный банк формулировок по всем доменам.
|
||||||
|
- `question_bank_massive_v2.json` — машиночитаемый банк вопросов для NCP/MCP/LLM-routing.
|
||||||
|
- `resolver_normalization_spec_v2.md` — спецификация нормализации фильтров и синонимов.
|
||||||
|
- `production_acceptance_pack_v2.md` — критерии приемки для live-runtime.
|
||||||
|
- `execution_backlog_v2.md` — последовательность внедрения до prod-ready.
|
||||||
|
- `domain_gap_register_v2.md` — регистр пробелов между текущим runtime и целевым продом.
|
||||||
|
|
||||||
|
## Ключевой принцип
|
||||||
|
|
||||||
|
Проблема должна решаться не точечной ручной настройкой под отдельные вопросы, а слоем:
|
||||||
|
`dialect variants -> canonical intent -> required filters -> recipe -> MCP -> factual output`.
|
||||||
|
|
||||||
|
## Объем
|
||||||
|
|
||||||
|
- intent'ов: 13
|
||||||
|
- вопросов/парафраз: 234
|
||||||
|
- доменов: 8
|
||||||
|
|
||||||
|
## Совместимость со стилем исходного архива
|
||||||
|
|
||||||
|
Пакет сохранен в том же формате, что и исходный архив:
|
||||||
|
- markdown-документы;
|
||||||
|
- таблицы intent/recipe/filter;
|
||||||
|
- отдельный question bank;
|
||||||
|
- acceptance/readiness артефакты.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Address Domain Matrix V2 (Prod Target)
|
||||||
|
Дата: 2026-03-30 Режим: `question_mode=address_query` (production extension)
|
||||||
|
## Scope
|
||||||
|
Этот слой охватывает direct factual lookup, агрегаты, drilldown и point-lookup по документам.
|
||||||
|
## Matrix
|
||||||
|
| scenario_id | domain | intent | recipe_id | required_filters | optional_filters | current_status | target_status | expected_response_type | priority |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| AQ-P0-01 | `contracts` | `list_open_contracts` | `address.open_contracts.by_asof` | `as_of_date` | `organization, counterparty, limit, sort` | STRUCTURALLY_VISIBLE / REQUIRES_SPECIALIZED_RECIPE | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P0-02 | `debt` | `list_payables_counterparties` | `address.payables.counterparty_totals` | `as_of_date` | `organization, account, limit, sort` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_SUMMARY` | P0 |
|
||||||
|
| AQ-P0-03 | `debt` | `list_receivables_counterparties` | `address.receivables.counterparty_totals` | `as_of_date` | `organization, account, limit, sort` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_SUMMARY` | P0 |
|
||||||
|
| AQ-P0-04 | `accounts` | `account_balance_snapshot` | `address.account.balance_snapshot` | `account, as_of_date` | `organization, limit, sort` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_SUMMARY` | P0 |
|
||||||
|
| AQ-P0-05 | `open_items` | `open_items_by_counterparty` | `address.open_items.by_counterparty` | `counterparty, as_of_date` | `organization, contract, account, limit` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P0-06 | `open_items` | `open_items_by_contract` | `address.open_items.by_contract` | `contract, as_of_date` | `organization, counterparty, account, limit` | STRUCTURALLY_VISIBLE / REQUIRES_SPECIALIZED_RECIPE | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P0-07 | `documents` | `list_documents_by_counterparty` | `address.documents.by_counterparty` | `counterparty, period_from, period_to` | `document_type, organization, limit, sort` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P0-07B | `bank_ops` | `bank_operations_by_counterparty` | `address.bank_ops.by_counterparty` | `counterparty` | `period_from, period_to, organization, limit, sort` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P0-08 | `documents` | `list_documents_by_contract` | `address.documents.by_contract` | `contract, period_from, period_to` | `document_type, organization, limit, sort` | STRUCTURALLY_VISIBLE / REQUIRES_SPECIALIZED_RECIPE | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P0-09 | `drilldown` | `documents_forming_balance` | `address.balance.drilldown_documents` | `account, as_of_date` | `organization, counterparty, contract, limit` | STRUCTURALLY_VISIBLE / LIVE_QUERYABLE_WITH_LIMITS | PROD_READY | `FACTUAL_LIST` | P0 |
|
||||||
|
| AQ-P1-10 | `accounts` | `account_turnover_snapshot` | `address.account.turnover_snapshot` | `account, period_from, period_to` | `organization, counterparty, limit` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_SUMMARY` | P1 |
|
||||||
|
| AQ-P1-11 | `documents` | `list_documents_by_type` | `address.documents.by_type` | `document_type, period_from, period_to` | `organization, counterparty, contract, limit` | STRUCTURALLY_VISIBLE / PLANNED | PROD_READY | `FACTUAL_LIST` | P1 |
|
||||||
|
| AQ-P1-12 | `document_lookup` | `find_document_by_number_and_date` | `address.document.lookup_by_number_date` | `document_number, document_date` | `document_type, organization, counterparty` | STRUCTURALLY_VISIBLE / NEW | PROD_READY | `FACTUAL_LIST` | P1 |
|
||||||
|
|
||||||
|
## Production Design Rules
|
||||||
|
|
||||||
|
- Для каждого intent должен существовать минимум один canonical recipe.
|
||||||
|
- Для каждого intent должны поддерживаться разговорные, бухгалтерские и сокращенные формулировки.
|
||||||
|
- Missing-filter fallback всегда явный: `LIMITED_WITH_REASON`, без ложного factual.
|
||||||
|
- Resolver работает отдельно от recipe и не зашивается в тексты примеров.
|
||||||
|
- Составные factual вопросы должны распадаться на подзапросы только после стабилизации single-intent покрытия.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Domain Gap Register V2
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## Регистр пробелов между текущим runtime и target prod
|
||||||
|
|
||||||
|
| domain | intent | current_gap | prod action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `contracts` | `list_open_contracts` | нет стабильного runtime recipe / resolver path | Нужен стабильный resolver по договору и единая логика open/closed. |
|
||||||
|
| `debt` | `list_payables_counterparties` | intent структурно описан, но не доведен до live-runtime | Ключевое покрытие бухгалтерского жаргона: кредиторка, задолженность перед поставщиками, долг поставщикам. |
|
||||||
|
| `debt` | `list_receivables_counterparties` | intent структурно описан, но не доведен до live-runtime | Нужны варианты: дебиторка, нам должны, покупатели должны, долг нам. |
|
||||||
|
| `accounts` | `account_balance_snapshot` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Главный блокер — account scope/materialization. |
|
||||||
|
| `open_items` | `open_items_by_counterparty` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Нужно покрыть жаргон: хвосты, незакрытое, висяк, остатки по взаиморасчетам. |
|
||||||
|
| `open_items` | `open_items_by_contract` | нет стабильного runtime recipe / resolver path | Требуется двухшаговый resolver contract->focused recipe. |
|
||||||
|
| `documents` | `list_documents_by_counterparty` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Нужно держать разные диалекты: по поставщику, по клиенту, по контре, движения по контрагенту. |
|
||||||
|
| `bank_ops` | `bank_operations_by_counterparty` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Варианты: оплаты, платежи, списания, поступления, банк по контрагенту. |
|
||||||
|
| `documents` | `list_documents_by_contract` | нет стабильного runtime recipe / resolver path | Нужна явная связка document <-> contract и фильтр по договорному идентификатору. |
|
||||||
|
| `drilldown` | `documents_forming_balance` | есть частичная live-проходимость, но недостаточная устойчивость по диалектам/anchor variants | Здесь особенно важны формулировки ‘расшифруй остаток’, ‘из чего сложился остаток’. |
|
||||||
|
| `accounts` | `account_turnover_snapshot` | intent структурно описан, но не доведен до live-runtime | Нужны варианты с оборотами, движением, дебет/кредит оборотами. |
|
||||||
|
| `documents` | `list_documents_by_type` | intent структурно описан, но не доведен до live-runtime | Важно распознавать русские и 1С-имена типа документа. |
|
||||||
|
| `document_lookup` | `find_document_by_number_and_date` | intent структурно описан, но не доведен до live-runtime | Берется прямо из question_bank_v1, полезно как частый пользовательский паттерн. |
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Execution Backlog V2
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## Принцип приоритезации
|
||||||
|
|
||||||
|
Сначала доводим до устойчивого prod те интенты, где:
|
||||||
|
- уже есть structural support;
|
||||||
|
- уже видны живые строки или близкий доступ к ним;
|
||||||
|
- высокий бизнес-спрос;
|
||||||
|
- много пользовательских вариантов формулировок.
|
||||||
|
|
||||||
|
## Wave 1 — Stabilize current liveable intents
|
||||||
|
|
||||||
|
1. `list_documents_by_counterparty`
|
||||||
|
2. `bank_operations_by_counterparty`
|
||||||
|
3. `documents_forming_balance`
|
||||||
|
4. `account_balance_snapshot`
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- расширяем paraphrase bank;
|
||||||
|
- включаем account token normalization;
|
||||||
|
- добавляем resolver confidence audit;
|
||||||
|
- собираем positive + negative curated cases.
|
||||||
|
|
||||||
|
## Wave 2 — Close core accounting gap
|
||||||
|
|
||||||
|
5. `list_payables_counterparties`
|
||||||
|
6. `list_receivables_counterparties`
|
||||||
|
7. `list_open_contracts`
|
||||||
|
8. `open_items_by_counterparty`
|
||||||
|
9. `open_items_by_contract`
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- фиксируем recipe white-list;
|
||||||
|
- готовим отдельный resolver слой по договору/контрагенту;
|
||||||
|
- вводим явный open-items taxonomy;
|
||||||
|
- проверяем account/account-family joins.
|
||||||
|
|
||||||
|
## Wave 3 — Expand discoverability
|
||||||
|
|
||||||
|
10. `list_documents_by_contract`
|
||||||
|
11. `account_turnover_snapshot`
|
||||||
|
12. `list_documents_by_type`
|
||||||
|
13. `find_document_by_number_and_date`
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- point lookup path;
|
||||||
|
- type resolver;
|
||||||
|
- унификация периодных фильтров;
|
||||||
|
- корпус live acceptance.
|
||||||
|
|
||||||
|
## Definition of Done для каждого intent
|
||||||
|
|
||||||
|
- 10+ устойчивых формулировок;
|
||||||
|
- canonical filters извлекаются стабильно;
|
||||||
|
- selected recipe детерминирован;
|
||||||
|
- есть curated positive evidence;
|
||||||
|
- negative twins безопасны;
|
||||||
|
- documented limitation taxonomy закрыта.
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
README.md
|
||||||
|
address_domain_matrix_v2.md
|
||||||
|
domain_gap_register_v2.md
|
||||||
|
execution_backlog_v2.md
|
||||||
|
manifest.txt
|
||||||
|
prod_domain_expansion_report.md
|
||||||
|
production_acceptance_pack_v2.md
|
||||||
|
query_paraphrase_bank_v2.md
|
||||||
|
question_bank_massive_v2.json
|
||||||
|
resolver_normalization_spec_v2.md
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Address Query Production Expansion Report
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## 1) Что именно решает этот пакет
|
||||||
|
|
||||||
|
Исходный архив хорошо описывает V1 runtime и фиксирует стартовый набор сценариев. Главная проблема, обозначенная пользователем, лежит не только в recipe-слое, а в связке:
|
||||||
|
|
||||||
|
`вариант формулировки -> intent -> filters -> resolver -> recipe`
|
||||||
|
|
||||||
|
Поэтому для доведения до prod недостаточно добавлять отдельные кейсы вручную. Нужно массовое покрытие домена через корпус формулировок и явные контракты нормализации.
|
||||||
|
|
||||||
|
## 2) Какой production target предлагается
|
||||||
|
|
||||||
|
### Production target = не “понимает один вопрос”, а:
|
||||||
|
|
||||||
|
- распознает 10+ естественных формулировок на intent;
|
||||||
|
- одинаково держит нейтральный, бухгалтерский и разговорный диалект;
|
||||||
|
- не разваливается от короткой/грязной формулировки;
|
||||||
|
- извлекает одинаковые canonical filters из разных phrasing;
|
||||||
|
- либо отдает factual, либо честно возвращает `LIMITED_WITH_REASON`.
|
||||||
|
|
||||||
|
## 3) Домены, которые надо считать целевыми
|
||||||
|
|
||||||
|
На базе исходного архива и question bank целевой контур такой:
|
||||||
|
|
||||||
|
1. задолженность / дебиторка / кредиторка;
|
||||||
|
2. открытые позиции / хвосты;
|
||||||
|
3. остатки по счетам;
|
||||||
|
4. обороты по счетам;
|
||||||
|
5. договоры;
|
||||||
|
6. документы по контрагенту;
|
||||||
|
7. документы по договору;
|
||||||
|
8. документы по типу;
|
||||||
|
9. банковские операции;
|
||||||
|
10. drilldown “какими документами сформирован остаток”;
|
||||||
|
11. point lookup документа по номеру/дате.
|
||||||
|
|
||||||
|
## 4) Что было слабым местом в исходном V1
|
||||||
|
|
||||||
|
- Сильнее всего был проработан counterparty/documents контур.
|
||||||
|
- Account family была частично видна, но упиралась в materialization/account-scope.
|
||||||
|
- Contract-family требовала специализированный resolver/recipe path.
|
||||||
|
- Массового корпуса диалектных формулировок не было: question bank был правильный по смыслу, но слишком компактный для продового routing.
|
||||||
|
|
||||||
|
## 5) Что добавлено в этот пакет
|
||||||
|
|
||||||
|
### A. Domain matrix V2
|
||||||
|
Фиксирует полный target-контур с required/optional filters и target recipe.
|
||||||
|
|
||||||
|
### B. Massive paraphrase bank
|
||||||
|
Дает корпус из 200+ формулировок, которые можно:
|
||||||
|
- использовать для декомпозиции и роутинга;
|
||||||
|
- пускать в automated batch acceptance;
|
||||||
|
- превращать в curated live suites.
|
||||||
|
|
||||||
|
### C. Normalization spec
|
||||||
|
Фиксирует, как переводить “контра/поставщик/клиент”, “хвост/остаток/сальдо”, “кредиторка/дебиторка” в stable intent/filter space.
|
||||||
|
|
||||||
|
### D. Production acceptance
|
||||||
|
Переводит качество из “кажется, местами работает” в измеримый acceptance по intent, диалекту и resolver quality.
|
||||||
|
|
||||||
|
## 6) Практическая стратегия внедрения
|
||||||
|
|
||||||
|
### Шаг 1. Freeze canonical intents
|
||||||
|
Не расширять бесконечно список intent'ов. Сначала закрепить канонический набор.
|
||||||
|
|
||||||
|
### Шаг 2. Attach paraphrase bank to normalizer tests
|
||||||
|
Каждая формулировка должна маппиться в один intent и один filter-contract.
|
||||||
|
|
||||||
|
### Шаг 3. Separate resolver failures from recipe failures
|
||||||
|
Сейчас часть “падений” пользователь видит как общую ошибку. В проде нужно видеть:
|
||||||
|
- не найден якорь;
|
||||||
|
- найден неоднозначно;
|
||||||
|
- recipe не поддерживает shape;
|
||||||
|
- raw rows есть, но materialization drops them.
|
||||||
|
|
||||||
|
### Шаг 4. Curated positive evidence
|
||||||
|
По каждому intent нужны реальные non-empty live кейсы. Без этого можно проверить только безопасность, но не полезность.
|
||||||
|
|
||||||
|
## 7) Рекомендуемая производственная последовательность
|
||||||
|
|
||||||
|
### Первая волна
|
||||||
|
- `list_documents_by_counterparty`
|
||||||
|
- `bank_operations_by_counterparty`
|
||||||
|
- `documents_forming_balance`
|
||||||
|
- `account_balance_snapshot`
|
||||||
|
|
||||||
|
### Вторая волна
|
||||||
|
- `list_payables_counterparties`
|
||||||
|
- `list_receivables_counterparties`
|
||||||
|
- `list_open_contracts`
|
||||||
|
- `open_items_by_counterparty`
|
||||||
|
- `open_items_by_contract`
|
||||||
|
|
||||||
|
### Третья волна
|
||||||
|
- `list_documents_by_contract`
|
||||||
|
- `account_turnover_snapshot`
|
||||||
|
- `list_documents_by_type`
|
||||||
|
- `find_document_by_number_and_date`
|
||||||
|
|
||||||
|
## 8) Ключевой управленческий вывод
|
||||||
|
|
||||||
|
Для этой задачи нужен не разовый набор “правильных вопросов”, а production corpus:
|
||||||
|
- intent matrix;
|
||||||
|
- resolver spec;
|
||||||
|
- massive paraphrase bank;
|
||||||
|
- acceptance pack.
|
||||||
|
|
||||||
|
Именно это позволяет перестать лечить каждый диалект вручную и перейти к системной доводке покрытия по доменам.
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Production Acceptance Pack V2
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Довести `address_query` до production-ready состояния на уровне:
|
||||||
|
- routing;
|
||||||
|
- intent detection;
|
||||||
|
- filter extraction;
|
||||||
|
- resolver robustness;
|
||||||
|
- factual stability;
|
||||||
|
- zero false factual.
|
||||||
|
|
||||||
|
## Обязательные suite'ы
|
||||||
|
|
||||||
|
### A. Routing & Intent Suite
|
||||||
|
Проверяет:
|
||||||
|
- mode routing;
|
||||||
|
- canonical intent resolution;
|
||||||
|
- detection of unsupported/causal queries;
|
||||||
|
- no deep-analysis regression.
|
||||||
|
|
||||||
|
### B. Dialect Robustness Suite
|
||||||
|
Проверяет:
|
||||||
|
- один и тот же intent в 10+ формулировках;
|
||||||
|
- бухгалтерский жаргон;
|
||||||
|
- разговорный диалект;
|
||||||
|
- сокращенные формулировки;
|
||||||
|
- уточняющие хвосты (`на сегодня`, `за период`, `по поставщику`).
|
||||||
|
|
||||||
|
### C. Resolver Robustness Suite
|
||||||
|
Проверяет:
|
||||||
|
- контрагент по полному имени;
|
||||||
|
- контрагент по короткому имени;
|
||||||
|
- договор по номеру;
|
||||||
|
- счет в разных форматах;
|
||||||
|
- тип документа по русскому и системному имени.
|
||||||
|
|
||||||
|
### D. Positive Evidence Suite
|
||||||
|
Проверяет:
|
||||||
|
- non-empty factual на curated live cases;
|
||||||
|
- стабильность recipe-level non-empty yield;
|
||||||
|
- отсутствие false factual.
|
||||||
|
|
||||||
|
### E. Negative Safety Suite
|
||||||
|
Проверяет:
|
||||||
|
- пустые или неоднозначные случаи возвращают `LIMITED_WITH_REASON`;
|
||||||
|
- нет молчаливой подмены anchor'а;
|
||||||
|
- нет ложных документов/сумм.
|
||||||
|
|
||||||
|
## Обязательные метрики
|
||||||
|
|
||||||
|
- `address_mode_routing_accuracy`
|
||||||
|
- `address_intent_resolution_rate`
|
||||||
|
- `dialect_variant_pass_rate`
|
||||||
|
- `required_filter_resolution_rate`
|
||||||
|
- `resolver_exact_or_high_confidence_rate`
|
||||||
|
- `live_call_success_rate`
|
||||||
|
- `factual_non_empty_rate`
|
||||||
|
- `false_factual_rate`
|
||||||
|
- `limited_reason_taxonomy_coverage`
|
||||||
|
|
||||||
|
## Целевые пороги
|
||||||
|
|
||||||
|
- `address_intent_resolution_rate >= 0.95`
|
||||||
|
- `dialect_variant_pass_rate >= 0.90`
|
||||||
|
- `required_filter_resolution_rate >= 0.95`
|
||||||
|
- `resolver_exact_or_high_confidence_rate >= 0.90`
|
||||||
|
- `live_call_success_rate >= 0.95`
|
||||||
|
- `false_factual_rate = 0`
|
||||||
|
|
||||||
|
## Минимальный debug contract
|
||||||
|
|
||||||
|
- `detected_mode`
|
||||||
|
- `detected_intent`
|
||||||
|
- `normalized_question`
|
||||||
|
- `resolved_filters`
|
||||||
|
- `missing_filters`
|
||||||
|
- `selected_recipe`
|
||||||
|
- resolver block
|
||||||
|
- MCP flow block
|
||||||
|
- `response_type`
|
||||||
|
- `limited_reason_category`
|
||||||
|
|
||||||
|
## Правило прод-приемки
|
||||||
|
|
||||||
|
Ни один домен не считается production-ready, пока он не прошел:
|
||||||
|
1. 10+ dialect variants на intent;
|
||||||
|
2. positive evidence;
|
||||||
|
3. negative twins;
|
||||||
|
4. ambiguity handling.
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
# Query Paraphrase Bank V2 — Address Query
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Этот файл нужен для массовой декомпозиции пользовательских запросов в canonical intent'ы. Для каждого intent даны вариации в разных стилях: нейтральный, бухгалтерский, разговорный, сокращенный, операционный.
|
||||||
|
|
||||||
|
## AQ-P0-01 — `list_open_contracts`
|
||||||
|
|
||||||
|
- domain: `contracts`
|
||||||
|
- recipe: `address.open_contracts.by_asof`
|
||||||
|
- required filters: `as_of_date`
|
||||||
|
- optional filters: `organization, counterparty, limit, sort`
|
||||||
|
- goal: Показать незакрытые договоры на дату с суммами и контрагентом.
|
||||||
|
|
||||||
|
- какие договоры не закрыты на 2020-07-31
|
||||||
|
- покажи незакрытые договоры на 2020-07-31
|
||||||
|
- что из договоров висит открытым на 2020-07-31
|
||||||
|
- есть ли открытые договоры на 2020-07-31
|
||||||
|
- выгрузи договоры с остатком на 2020-07-31
|
||||||
|
- по каким договорам на 2020-07-31 еще нет закрытия
|
||||||
|
- дай список незакрытых договоров на 2020-07-31
|
||||||
|
- какие договоры остаются открытыми на 2020-07-31
|
||||||
|
- какие договоры болтаются незакрытыми на 2020-07-31
|
||||||
|
- покажи договоры, по которым есть хвост на 2020-07-31
|
||||||
|
- какие договоры в базе не закрыты на 2020-07-31
|
||||||
|
- что по открытым договорам на 2020-07-31
|
||||||
|
- сформируй перечень открытых договоров на 2020-07-31
|
||||||
|
- есть незакрытые договоры по состоянию на 2020-07-31
|
||||||
|
- какие договоры не схлопнулись на 2020-07-31
|
||||||
|
- по каким договорам висят остатки на 2020-07-31
|
||||||
|
- какие договоры не сведены на 2020-07-31
|
||||||
|
- открой договоры со сальдо на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-02 — `list_payables_counterparties`
|
||||||
|
|
||||||
|
- domain: `debt`
|
||||||
|
- recipe: `address.payables.counterparty_totals`
|
||||||
|
- required filters: `as_of_date`
|
||||||
|
- optional filters: `organization, account, limit, sort`
|
||||||
|
- goal: Определить, кому компания должна на дату.
|
||||||
|
|
||||||
|
- кому мы должны на 2020-07-31
|
||||||
|
- по кому у нас кредиторка на 2020-07-31
|
||||||
|
- покажи задолженность перед контрагентами на 2020-07-31
|
||||||
|
- какие поставщики ждут оплату на 2020-07-31
|
||||||
|
- кому должны денег по состоянию на 2020-07-31
|
||||||
|
- у кого перед нами нет оплаты? нет, наоборот: кому должны мы на 2020-07-31
|
||||||
|
- выведи кредиторскую задолженность на 2020-07-31
|
||||||
|
- кому нужно платить на 2020-07-31
|
||||||
|
- какие долги перед поставщиками висят на 2020-07-31
|
||||||
|
- контрагенты, которым мы должны на 2020-07-31
|
||||||
|
- по кому висит наш долг на 2020-07-31
|
||||||
|
- дай список кредиторов на 2020-07-31
|
||||||
|
- какая задолженность у нас перед поставщиками на 2020-07-31
|
||||||
|
- кому организация должна по взаиморасчетам на 2020-07-31
|
||||||
|
- раскрой кредиторку на 2020-07-31
|
||||||
|
- покажи кому мы должны денег по базе на 2020-07-31
|
||||||
|
- у каких контрагентов у нас минус на 2020-07-31
|
||||||
|
- по каким контрагентам надо закрывать долг на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-03 — `list_receivables_counterparties`
|
||||||
|
|
||||||
|
- domain: `debt`
|
||||||
|
- recipe: `address.receivables.counterparty_totals`
|
||||||
|
- required filters: `as_of_date`
|
||||||
|
- optional filters: `organization, account, limit, sort`
|
||||||
|
- goal: Определить, кто должен компании на дату.
|
||||||
|
|
||||||
|
- кто должен нам на 2020-07-31
|
||||||
|
- по кому у нас дебиторка на 2020-07-31
|
||||||
|
- покажи задолженность контрагентов перед нами на 2020-07-31
|
||||||
|
- какие покупатели нам должны на 2020-07-31
|
||||||
|
- кто не оплатил нам на 2020-07-31
|
||||||
|
- выведи дебиторскую задолженность на 2020-07-31
|
||||||
|
- кто висит в должниках на 2020-07-31
|
||||||
|
- какие долги нам торчат на 2020-07-31
|
||||||
|
- контрагенты, которые должны нам на 2020-07-31
|
||||||
|
- по кому висит долг нам на 2020-07-31
|
||||||
|
- дай список должников на 2020-07-31
|
||||||
|
- какая дебиторка у нас на 2020-07-31
|
||||||
|
- покажи, кто не закрылся перед нами на 2020-07-31
|
||||||
|
- кто должен организации по взаиморасчетам на 2020-07-31
|
||||||
|
- раскрой дебиторку на 2020-07-31
|
||||||
|
- у каких контрагентов есть долг перед нами на 2020-07-31
|
||||||
|
- по каким покупателям есть хвост на 2020-07-31
|
||||||
|
- кого нужно дожимать по оплате на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-04 — `account_balance_snapshot`
|
||||||
|
|
||||||
|
- domain: `accounts`
|
||||||
|
- recipe: `address.account.balance_snapshot`
|
||||||
|
- required filters: `account, as_of_date`
|
||||||
|
- optional filters: `organization, limit, sort`
|
||||||
|
- goal: Показать остаток по счету на дату.
|
||||||
|
|
||||||
|
- какой остаток по счету 62 на 2020-07-31
|
||||||
|
- покажи остаток по 62 счету на 2020-07-31
|
||||||
|
- что висит на счете 62 на 2020-07-31
|
||||||
|
- дай сальдо по счету 62 на 2020-07-31
|
||||||
|
- какой хвост на 62 на 2020-07-31
|
||||||
|
- какой остаток висит по 62 на 2020-07-31
|
||||||
|
- сколько на 62 счете на 2020-07-31
|
||||||
|
- сформируй остаток по счету 62 по состоянию на 2020-07-31
|
||||||
|
- раскрой остаток по счету 62 на 2020-07-31
|
||||||
|
- покажи сальдо по 62 на 2020-07-31
|
||||||
|
- что осталось на 62 счете на 2020-07-31
|
||||||
|
- по счету 62 какой баланс на 2020-07-31
|
||||||
|
- какой остаток по бухсчету 62 на 2020-07-31
|
||||||
|
- на 2020-07-31 сколько висит на счете 62
|
||||||
|
- остаток по плану счетов 62 на 2020-07-31
|
||||||
|
- какое сальдо по 62 по базе на 2020-07-31
|
||||||
|
- дай остаток по счету 62 с учетом проводок на 2020-07-31
|
||||||
|
- что по остатку счета 62 на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-05 — `open_items_by_counterparty`
|
||||||
|
|
||||||
|
- domain: `open_items`
|
||||||
|
- recipe: `address.open_items.by_counterparty`
|
||||||
|
- required filters: `counterparty, as_of_date`
|
||||||
|
- optional filters: `organization, contract, account, limit`
|
||||||
|
- goal: Показать хвосты / открытые позиции по контрагенту.
|
||||||
|
|
||||||
|
- какие хвосты висят по контрагенту ООО Альфа на 2020-07-31
|
||||||
|
- что не закрыто по ООО Альфа на 2020-07-31
|
||||||
|
- покажи открытые позиции по контрагенту ООО Альфа на 2020-07-31
|
||||||
|
- какие незакрытые взаиморасчеты по ООО Альфа на 2020-07-31
|
||||||
|
- какие долги/хвосты по ООО Альфа на 2020-07-31
|
||||||
|
- что висит по контрагенту ООО Альфа на 2020-07-31
|
||||||
|
- раскрой висяки по ООО Альфа на 2020-07-31
|
||||||
|
- покажи незакрытое по ООО Альфа на 2020-07-31
|
||||||
|
- какие остатки по взаиморасчетам с ООО Альфа на 2020-07-31
|
||||||
|
- есть ли хвосты по ООО Альфа на 2020-07-31
|
||||||
|
- по ООО Альфа что осталось открытым на 2020-07-31
|
||||||
|
- дай открытые документы/позиции по ООО Альфа на 2020-07-31
|
||||||
|
- покажи висящие суммы по ООО Альфа на 2020-07-31
|
||||||
|
- что не схлопнулось по контрагенту ООО Альфа на 2020-07-31
|
||||||
|
- по поставщику ООО Альфа какие хвосты на 2020-07-31
|
||||||
|
- по клиенту ООО Альфа что не закрыто на 2020-07-31
|
||||||
|
- взаиморасчеты с ООО Альфа: что висит на 2020-07-31
|
||||||
|
- покажи остатки по контрагенту ООО Альфа на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-06 — `open_items_by_contract`
|
||||||
|
|
||||||
|
- domain: `open_items`
|
||||||
|
- recipe: `address.open_items.by_contract`
|
||||||
|
- required filters: `contract, as_of_date`
|
||||||
|
- optional filters: `organization, counterparty, account, limit`
|
||||||
|
- goal: Показать хвосты / открытые позиции по договору.
|
||||||
|
|
||||||
|
- какие хвосты висят по договору 15/24 на 2020-07-31
|
||||||
|
- что не закрыто по договору 15/24 на 2020-07-31
|
||||||
|
- покажи открытые позиции по договору 15/24 на 2020-07-31
|
||||||
|
- какие незакрытые взаиморасчеты по договору 15/24 на 2020-07-31
|
||||||
|
- что висит по договору 15/24 на 2020-07-31
|
||||||
|
- раскрой висяки по договору 15/24 на 2020-07-31
|
||||||
|
- покажи незакрытое по договору 15/24 на 2020-07-31
|
||||||
|
- какие остатки по договору 15/24 на 2020-07-31
|
||||||
|
- есть ли хвосты по договору 15/24 на 2020-07-31
|
||||||
|
- по договору 15/24 что осталось открытым на 2020-07-31
|
||||||
|
- дай открытые документы/позиции по договору 15/24 на 2020-07-31
|
||||||
|
- покажи висящие суммы по договору 15/24 на 2020-07-31
|
||||||
|
- что не схлопнулось по договору 15/24 на 2020-07-31
|
||||||
|
- по договору 15/24 какие хвосты на 2020-07-31
|
||||||
|
- взаиморасчеты по договору 15/24: что висит на 2020-07-31
|
||||||
|
- покажи остатки по договору 15/24 на 2020-07-31
|
||||||
|
- какие позиции не закрыты по договору 15/24 на 2020-07-31
|
||||||
|
- дай хвосты по договору 15/24 по состоянию на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-07 — `list_documents_by_counterparty`
|
||||||
|
|
||||||
|
- domain: `documents`
|
||||||
|
- recipe: `address.documents.by_counterparty`
|
||||||
|
- required filters: `counterparty, period_from, period_to`
|
||||||
|
- optional filters: `document_type, organization, limit, sort`
|
||||||
|
- goal: Показать документы по контрагенту за период.
|
||||||
|
|
||||||
|
- покажи документы по контрагенту ООО Альфа за период 2020-07-01..2020-07-31
|
||||||
|
- дай документы по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- какие документы были по контрагенту ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- выгрузи документы по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- покажи движения документов по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- что за документы проходили по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- дай первичку по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- какие документы по поставщику ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- какие документы по клиенту ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- найди документы по контре ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- список документов по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- покажи все документы по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- по контрагенту ООО Альфа какие документы были в 2020-07-01..2020-07-31
|
||||||
|
- по ООО Альфа дай документы за 2020-07-01..2020-07-31
|
||||||
|
- какие проведенные документы по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- по поставщику ООО Альфа покажи документы за 2020-07-01..2020-07-31
|
||||||
|
- по контрагенту ООО Альфа нужна выгрузка документов за 2020-07-01..2020-07-31
|
||||||
|
- документальный след по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-07B — `bank_operations_by_counterparty`
|
||||||
|
|
||||||
|
- domain: `bank_ops`
|
||||||
|
- recipe: `address.bank_ops.by_counterparty`
|
||||||
|
- required filters: `counterparty`
|
||||||
|
- optional filters: `period_from, period_to, organization, limit, sort`
|
||||||
|
- goal: Показать банковские операции по контрагенту.
|
||||||
|
|
||||||
|
- покажи банковские операции по контрагенту ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- какие платежи были по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- были ли оплаты по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- дай банк по контрагенту ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- покажи движения по банку с ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- какие списания/поступления были по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- выгрузи банковские документы по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- покажи платежки по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- что проходило по расчетному счету с ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- какие оплаты поставщику ООО Альфа были за 2020-07-01..2020-07-31
|
||||||
|
- какие поступления от ООО Альфа были за 2020-07-01..2020-07-31
|
||||||
|
- найди банковские операции по контре ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- список оплат по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- покажи банк по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- по контрагенту ООО Альфа какие платежи шли в 2020-07-01..2020-07-31
|
||||||
|
- по ООО Альфа нужна банковская выборка за 2020-07-01..2020-07-31
|
||||||
|
- были ли платежные движения по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
- все банковские проходы по ООО Альфа за 2020-07-01..2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-08 — `list_documents_by_contract`
|
||||||
|
|
||||||
|
- domain: `documents`
|
||||||
|
- recipe: `address.documents.by_contract`
|
||||||
|
- required filters: `contract, period_from, period_to`
|
||||||
|
- optional filters: `document_type, organization, limit, sort`
|
||||||
|
- goal: Показать документы по договору за период.
|
||||||
|
|
||||||
|
- покажи документы по договору 15/24 за период 2020-07-01..2020-07-31
|
||||||
|
- дай документы по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- какие документы были по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- выгрузи документы по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- что за документы проходили по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- дай первичку по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- список документов по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- покажи все документы по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- по договору 15/24 какие документы были в 2020-07-01..2020-07-31
|
||||||
|
- по 15/24 дай документы за 2020-07-01..2020-07-31
|
||||||
|
- какие проведенные документы по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- по договору 15/24 покажи документы за 2020-07-01..2020-07-31
|
||||||
|
- по договору 15/24 нужна выгрузка документов за 2020-07-01..2020-07-31
|
||||||
|
- документальный след по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- есть ли документы по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- покажи движения документов по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- что оформляли по договору 15/24 за 2020-07-01..2020-07-31
|
||||||
|
- все документы, связанные с договором 15/24, за 2020-07-01..2020-07-31
|
||||||
|
|
||||||
|
## AQ-P0-09 — `documents_forming_balance`
|
||||||
|
|
||||||
|
- domain: `drilldown`
|
||||||
|
- recipe: `address.balance.drilldown_documents`
|
||||||
|
- required filters: `account, as_of_date`
|
||||||
|
- optional filters: `organization, counterparty, contract, limit`
|
||||||
|
- goal: Раскрыть, какими документами сформирован остаток по счету.
|
||||||
|
|
||||||
|
- какие документы формируют остаток по счету 62 на 2020-07-31
|
||||||
|
- расшифруй остаток по счету 62 на 2020-07-31 по документам
|
||||||
|
- из каких документов сложился остаток по 62 на 2020-07-31
|
||||||
|
- покажи документы, которые дали остаток по счету 62 на 2020-07-31
|
||||||
|
- чем сформирован остаток по 62 на 2020-07-31
|
||||||
|
- какие документы висят в остатке по 62 на 2020-07-31
|
||||||
|
- раскрой остаток по 62 на 2020-07-31 до документов
|
||||||
|
- дай документную расшифровку остатка по счету 62 на 2020-07-31
|
||||||
|
- по счету 62 какие документы формируют сальдо на 2020-07-31
|
||||||
|
- покажи первичку, которая сидит в остатке 62 на 2020-07-31
|
||||||
|
- что конкретно образует остаток по 62 на 2020-07-31
|
||||||
|
- какие регистраторы формируют остаток счета 62 на 2020-07-31
|
||||||
|
- разложи остаток по счету 62 на 2020-07-31 на документы
|
||||||
|
- по 62 на 2020-07-31 нужен drilldown до документов
|
||||||
|
- остаток счета 62 на 2020-07-31: из чего он состоит
|
||||||
|
- какие документы попали в хвост по счету 62 на 2020-07-31
|
||||||
|
- раскрой сальдо 62 на 2020-07-31 по документам
|
||||||
|
- что формирует баланс по счету 62 на 2020-07-31
|
||||||
|
|
||||||
|
## AQ-P1-10 — `account_turnover_snapshot`
|
||||||
|
|
||||||
|
- domain: `accounts`
|
||||||
|
- recipe: `address.account.turnover_snapshot`
|
||||||
|
- required filters: `account, period_from, period_to`
|
||||||
|
- optional filters: `organization, counterparty, limit`
|
||||||
|
- goal: Показать обороты по счету за период.
|
||||||
|
|
||||||
|
- дай обороты по счету 62 за 2020-07-01..2020-07-31
|
||||||
|
- покажи движение по счету 62 за 2020-07-01..2020-07-31
|
||||||
|
- какие обороты по 62 были за 2020-07-01..2020-07-31
|
||||||
|
- выведи дебет/кредит обороты по счету 62 за 2020-07-01..2020-07-31
|
||||||
|
- сформируй оборотку по счету 62 за 2020-07-01..2020-07-31
|
||||||
|
- по счету 62 какие обороты в 2020-07-01..2020-07-31
|
||||||
|
- дай обороты по бухсчету 62 за 2020-07-01..2020-07-31
|
||||||
|
- что прошло по счету 62 за 2020-07-01..2020-07-31
|
||||||
|
- обороты по 62 счету за 2020-07-01..2020-07-31
|
||||||
|
- покажи обороты счета 62 за 2020-07-01..2020-07-31
|
||||||
|
- какое движение по 62 за 2020-07-01..2020-07-31
|
||||||
|
- дай сумму оборотов по 62 за 2020-07-01..2020-07-31
|
||||||
|
- по 62 раскрой дебетовые и кредитовые обороты за 2020-07-01..2020-07-31
|
||||||
|
- какие суммы крутились по счету 62 за 2020-07-01..2020-07-31
|
||||||
|
- покажи оборот по плану счетов 62 за 2020-07-01..2020-07-31
|
||||||
|
- счет 62: движение за 2020-07-01..2020-07-31
|
||||||
|
- что было по дебету/кредиту счета 62 за 2020-07-01..2020-07-31
|
||||||
|
- нужны обороты по 62 за 2020-07-01..2020-07-31
|
||||||
|
|
||||||
|
## AQ-P1-11 — `list_documents_by_type`
|
||||||
|
|
||||||
|
- domain: `documents`
|
||||||
|
- recipe: `address.documents.by_type`
|
||||||
|
- required filters: `document_type, period_from, period_to`
|
||||||
|
- optional filters: `organization, counterparty, contract, limit`
|
||||||
|
- goal: Показать документы определенного вида за период.
|
||||||
|
|
||||||
|
- дай документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- покажи документы вида СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- какие документы СписаниеСРасчетногоСчета есть за 2020-07-01..2020-07-31
|
||||||
|
- выгрузи документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- список документов СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- покажи все СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- какие проведенные документы типа СписаниеСРасчетногоСчета были за 2020-07-01..2020-07-31
|
||||||
|
- документы вида СписаниеСРасчетногоСчета в 2020-07-01..2020-07-31
|
||||||
|
- по типу СписаниеСРасчетногоСчета покажи документы за 2020-07-01..2020-07-31
|
||||||
|
- что есть по документам типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- дай выборку по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- покажи реестр СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- по виду документа СписаниеСРасчетногоСчета нужна выгрузка за 2020-07-01..2020-07-31
|
||||||
|
- какие экземпляры СписаниеСРасчетногоСчета проходили за 2020-07-01..2020-07-31
|
||||||
|
- документы 1С типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- найди документы СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- выведи журнал по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31
|
||||||
|
- какие документы этого типа были оформлены за 2020-07-01..2020-07-31
|
||||||
|
|
||||||
|
## AQ-P1-12 — `find_document_by_number_and_date`
|
||||||
|
|
||||||
|
- domain: `document_lookup`
|
||||||
|
- recipe: `address.document.lookup_by_number_date`
|
||||||
|
- required filters: `document_number, document_date`
|
||||||
|
- optional filters: `document_type, organization, counterparty`
|
||||||
|
- goal: Найти конкретный документ по номеру и дате.
|
||||||
|
|
||||||
|
- найди документ № 000123 от 2020-07-15
|
||||||
|
- покажи документ номер 000123 от 2020-07-15
|
||||||
|
- есть ли документ 000123 от 2020-07-15
|
||||||
|
- отыщи документ № 000123 датой 2020-07-15
|
||||||
|
- нужен документ 000123 от 2020-07-15
|
||||||
|
- в базе есть документ 000123 от 2020-07-15
|
||||||
|
- подними документ номер 000123 от 2020-07-15
|
||||||
|
- покажи, что за документ 000123 от 2020-07-15
|
||||||
|
- дай карточку документа 000123 от 2020-07-15
|
||||||
|
- ищу документ 000123 от 2020-07-15
|
||||||
|
- найди по номеру 000123 и дате 2020-07-15
|
||||||
|
- проверь наличие документа 000123 от 2020-07-15
|
||||||
|
- покажи запись по документу 000123 от 2020-07-15
|
||||||
|
- где документ 000123 от 2020-07-15
|
||||||
|
- какой документ проходит под номером 000123 от 2020-07-15
|
||||||
|
- вытащи документ 000123 от 2020-07-15
|
||||||
|
- документ номер 000123, дата 2020-07-15
|
||||||
|
- дай документ по реквизитам: номер 000123, дата 2020-07-15
|
||||||
|
|
||||||
|
|
@ -0,0 +1,487 @@
|
||||||
|
{
|
||||||
|
"generated_at": "2026-03-30",
|
||||||
|
"package": "address_query_prod_pack_v2",
|
||||||
|
"total_intents": 13,
|
||||||
|
"total_questions": 234,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-01",
|
||||||
|
"domain": "contracts",
|
||||||
|
"intent": "list_open_contracts",
|
||||||
|
"recipe_id": "address.open_contracts.by_asof",
|
||||||
|
"required_filters": [
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"counterparty",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"какие договоры не закрыты на 2020-07-31",
|
||||||
|
"покажи незакрытые договоры на 2020-07-31",
|
||||||
|
"что из договоров висит открытым на 2020-07-31",
|
||||||
|
"есть ли открытые договоры на 2020-07-31",
|
||||||
|
"выгрузи договоры с остатком на 2020-07-31",
|
||||||
|
"по каким договорам на 2020-07-31 еще нет закрытия",
|
||||||
|
"дай список незакрытых договоров на 2020-07-31",
|
||||||
|
"какие договоры остаются открытыми на 2020-07-31",
|
||||||
|
"какие договоры болтаются незакрытыми на 2020-07-31",
|
||||||
|
"покажи договоры, по которым есть хвост на 2020-07-31",
|
||||||
|
"какие договоры в базе не закрыты на 2020-07-31",
|
||||||
|
"что по открытым договорам на 2020-07-31",
|
||||||
|
"сформируй перечень открытых договоров на 2020-07-31",
|
||||||
|
"есть незакрытые договоры по состоянию на 2020-07-31",
|
||||||
|
"какие договоры не схлопнулись на 2020-07-31",
|
||||||
|
"по каким договорам висят остатки на 2020-07-31",
|
||||||
|
"какие договоры не сведены на 2020-07-31",
|
||||||
|
"открой договоры со сальдо на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-02",
|
||||||
|
"domain": "debt",
|
||||||
|
"intent": "list_payables_counterparties",
|
||||||
|
"recipe_id": "address.payables.counterparty_totals",
|
||||||
|
"required_filters": [
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"account",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_SUMMARY",
|
||||||
|
"questions": [
|
||||||
|
"кому мы должны на 2020-07-31",
|
||||||
|
"по кому у нас кредиторка на 2020-07-31",
|
||||||
|
"покажи задолженность перед контрагентами на 2020-07-31",
|
||||||
|
"какие поставщики ждут оплату на 2020-07-31",
|
||||||
|
"кому должны денег по состоянию на 2020-07-31",
|
||||||
|
"у кого перед нами нет оплаты? нет, наоборот: кому должны мы на 2020-07-31",
|
||||||
|
"выведи кредиторскую задолженность на 2020-07-31",
|
||||||
|
"кому нужно платить на 2020-07-31",
|
||||||
|
"какие долги перед поставщиками висят на 2020-07-31",
|
||||||
|
"контрагенты, которым мы должны на 2020-07-31",
|
||||||
|
"по кому висит наш долг на 2020-07-31",
|
||||||
|
"дай список кредиторов на 2020-07-31",
|
||||||
|
"какая задолженность у нас перед поставщиками на 2020-07-31",
|
||||||
|
"кому организация должна по взаиморасчетам на 2020-07-31",
|
||||||
|
"раскрой кредиторку на 2020-07-31",
|
||||||
|
"покажи кому мы должны денег по базе на 2020-07-31",
|
||||||
|
"у каких контрагентов у нас минус на 2020-07-31",
|
||||||
|
"по каким контрагентам надо закрывать долг на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-03",
|
||||||
|
"domain": "debt",
|
||||||
|
"intent": "list_receivables_counterparties",
|
||||||
|
"recipe_id": "address.receivables.counterparty_totals",
|
||||||
|
"required_filters": [
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"account",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_SUMMARY",
|
||||||
|
"questions": [
|
||||||
|
"кто должен нам на 2020-07-31",
|
||||||
|
"по кому у нас дебиторка на 2020-07-31",
|
||||||
|
"покажи задолженность контрагентов перед нами на 2020-07-31",
|
||||||
|
"какие покупатели нам должны на 2020-07-31",
|
||||||
|
"кто не оплатил нам на 2020-07-31",
|
||||||
|
"выведи дебиторскую задолженность на 2020-07-31",
|
||||||
|
"кто висит в должниках на 2020-07-31",
|
||||||
|
"какие долги нам торчат на 2020-07-31",
|
||||||
|
"контрагенты, которые должны нам на 2020-07-31",
|
||||||
|
"по кому висит долг нам на 2020-07-31",
|
||||||
|
"дай список должников на 2020-07-31",
|
||||||
|
"какая дебиторка у нас на 2020-07-31",
|
||||||
|
"покажи, кто не закрылся перед нами на 2020-07-31",
|
||||||
|
"кто должен организации по взаиморасчетам на 2020-07-31",
|
||||||
|
"раскрой дебиторку на 2020-07-31",
|
||||||
|
"у каких контрагентов есть долг перед нами на 2020-07-31",
|
||||||
|
"по каким покупателям есть хвост на 2020-07-31",
|
||||||
|
"кого нужно дожимать по оплате на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-04",
|
||||||
|
"domain": "accounts",
|
||||||
|
"intent": "account_balance_snapshot",
|
||||||
|
"recipe_id": "address.account.balance_snapshot",
|
||||||
|
"required_filters": [
|
||||||
|
"account",
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_SUMMARY",
|
||||||
|
"questions": [
|
||||||
|
"какой остаток по счету 62 на 2020-07-31",
|
||||||
|
"покажи остаток по 62 счету на 2020-07-31",
|
||||||
|
"что висит на счете 62 на 2020-07-31",
|
||||||
|
"дай сальдо по счету 62 на 2020-07-31",
|
||||||
|
"какой хвост на 62 на 2020-07-31",
|
||||||
|
"какой остаток висит по 62 на 2020-07-31",
|
||||||
|
"сколько на 62 счете на 2020-07-31",
|
||||||
|
"сформируй остаток по счету 62 по состоянию на 2020-07-31",
|
||||||
|
"раскрой остаток по счету 62 на 2020-07-31",
|
||||||
|
"покажи сальдо по 62 на 2020-07-31",
|
||||||
|
"что осталось на 62 счете на 2020-07-31",
|
||||||
|
"по счету 62 какой баланс на 2020-07-31",
|
||||||
|
"какой остаток по бухсчету 62 на 2020-07-31",
|
||||||
|
"на 2020-07-31 сколько висит на счете 62",
|
||||||
|
"остаток по плану счетов 62 на 2020-07-31",
|
||||||
|
"какое сальдо по 62 по базе на 2020-07-31",
|
||||||
|
"дай остаток по счету 62 с учетом проводок на 2020-07-31",
|
||||||
|
"что по остатку счета 62 на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-05",
|
||||||
|
"domain": "open_items",
|
||||||
|
"intent": "open_items_by_counterparty",
|
||||||
|
"recipe_id": "address.open_items.by_counterparty",
|
||||||
|
"required_filters": [
|
||||||
|
"counterparty",
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"contract",
|
||||||
|
"account",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"какие хвосты висят по контрагенту ООО Альфа на 2020-07-31",
|
||||||
|
"что не закрыто по ООО Альфа на 2020-07-31",
|
||||||
|
"покажи открытые позиции по контрагенту ООО Альфа на 2020-07-31",
|
||||||
|
"какие незакрытые взаиморасчеты по ООО Альфа на 2020-07-31",
|
||||||
|
"какие долги/хвосты по ООО Альфа на 2020-07-31",
|
||||||
|
"что висит по контрагенту ООО Альфа на 2020-07-31",
|
||||||
|
"раскрой висяки по ООО Альфа на 2020-07-31",
|
||||||
|
"покажи незакрытое по ООО Альфа на 2020-07-31",
|
||||||
|
"какие остатки по взаиморасчетам с ООО Альфа на 2020-07-31",
|
||||||
|
"есть ли хвосты по ООО Альфа на 2020-07-31",
|
||||||
|
"по ООО Альфа что осталось открытым на 2020-07-31",
|
||||||
|
"дай открытые документы/позиции по ООО Альфа на 2020-07-31",
|
||||||
|
"покажи висящие суммы по ООО Альфа на 2020-07-31",
|
||||||
|
"что не схлопнулось по контрагенту ООО Альфа на 2020-07-31",
|
||||||
|
"по поставщику ООО Альфа какие хвосты на 2020-07-31",
|
||||||
|
"по клиенту ООО Альфа что не закрыто на 2020-07-31",
|
||||||
|
"взаиморасчеты с ООО Альфа: что висит на 2020-07-31",
|
||||||
|
"покажи остатки по контрагенту ООО Альфа на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-06",
|
||||||
|
"domain": "open_items",
|
||||||
|
"intent": "open_items_by_contract",
|
||||||
|
"recipe_id": "address.open_items.by_contract",
|
||||||
|
"required_filters": [
|
||||||
|
"contract",
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"counterparty",
|
||||||
|
"account",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"какие хвосты висят по договору 15/24 на 2020-07-31",
|
||||||
|
"что не закрыто по договору 15/24 на 2020-07-31",
|
||||||
|
"покажи открытые позиции по договору 15/24 на 2020-07-31",
|
||||||
|
"какие незакрытые взаиморасчеты по договору 15/24 на 2020-07-31",
|
||||||
|
"что висит по договору 15/24 на 2020-07-31",
|
||||||
|
"раскрой висяки по договору 15/24 на 2020-07-31",
|
||||||
|
"покажи незакрытое по договору 15/24 на 2020-07-31",
|
||||||
|
"какие остатки по договору 15/24 на 2020-07-31",
|
||||||
|
"есть ли хвосты по договору 15/24 на 2020-07-31",
|
||||||
|
"по договору 15/24 что осталось открытым на 2020-07-31",
|
||||||
|
"дай открытые документы/позиции по договору 15/24 на 2020-07-31",
|
||||||
|
"покажи висящие суммы по договору 15/24 на 2020-07-31",
|
||||||
|
"что не схлопнулось по договору 15/24 на 2020-07-31",
|
||||||
|
"по договору 15/24 какие хвосты на 2020-07-31",
|
||||||
|
"взаиморасчеты по договору 15/24: что висит на 2020-07-31",
|
||||||
|
"покажи остатки по договору 15/24 на 2020-07-31",
|
||||||
|
"какие позиции не закрыты по договору 15/24 на 2020-07-31",
|
||||||
|
"дай хвосты по договору 15/24 по состоянию на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-07",
|
||||||
|
"domain": "documents",
|
||||||
|
"intent": "list_documents_by_counterparty",
|
||||||
|
"recipe_id": "address.documents.by_counterparty",
|
||||||
|
"required_filters": [
|
||||||
|
"counterparty",
|
||||||
|
"period_from",
|
||||||
|
"period_to"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"document_type",
|
||||||
|
"organization",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"покажи документы по контрагенту ООО Альфа за период 2020-07-01..2020-07-31",
|
||||||
|
"дай документы по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"какие документы были по контрагенту ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"выгрузи документы по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"покажи движения документов по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"что за документы проходили по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"дай первичку по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"какие документы по поставщику ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"какие документы по клиенту ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"найди документы по контре ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"список документов по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"покажи все документы по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"по контрагенту ООО Альфа какие документы были в 2020-07-01..2020-07-31",
|
||||||
|
"по ООО Альфа дай документы за 2020-07-01..2020-07-31",
|
||||||
|
"какие проведенные документы по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"по поставщику ООО Альфа покажи документы за 2020-07-01..2020-07-31",
|
||||||
|
"по контрагенту ООО Альфа нужна выгрузка документов за 2020-07-01..2020-07-31",
|
||||||
|
"документальный след по ООО Альфа за 2020-07-01..2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-07B",
|
||||||
|
"domain": "bank_ops",
|
||||||
|
"intent": "bank_operations_by_counterparty",
|
||||||
|
"recipe_id": "address.bank_ops.by_counterparty",
|
||||||
|
"required_filters": [
|
||||||
|
"counterparty"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"period_from",
|
||||||
|
"period_to",
|
||||||
|
"organization",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"покажи банковские операции по контрагенту ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"какие платежи были по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"были ли оплаты по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"дай банк по контрагенту ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"покажи движения по банку с ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"какие списания/поступления были по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"выгрузи банковские документы по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"покажи платежки по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"что проходило по расчетному счету с ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"какие оплаты поставщику ООО Альфа были за 2020-07-01..2020-07-31",
|
||||||
|
"какие поступления от ООО Альфа были за 2020-07-01..2020-07-31",
|
||||||
|
"найди банковские операции по контре ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"список оплат по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"покажи банк по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"по контрагенту ООО Альфа какие платежи шли в 2020-07-01..2020-07-31",
|
||||||
|
"по ООО Альфа нужна банковская выборка за 2020-07-01..2020-07-31",
|
||||||
|
"были ли платежные движения по ООО Альфа за 2020-07-01..2020-07-31",
|
||||||
|
"все банковские проходы по ООО Альфа за 2020-07-01..2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-08",
|
||||||
|
"domain": "documents",
|
||||||
|
"intent": "list_documents_by_contract",
|
||||||
|
"recipe_id": "address.documents.by_contract",
|
||||||
|
"required_filters": [
|
||||||
|
"contract",
|
||||||
|
"period_from",
|
||||||
|
"period_to"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"document_type",
|
||||||
|
"organization",
|
||||||
|
"limit",
|
||||||
|
"sort"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"покажи документы по договору 15/24 за период 2020-07-01..2020-07-31",
|
||||||
|
"дай документы по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"какие документы были по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"выгрузи документы по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"что за документы проходили по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"дай первичку по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"список документов по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"покажи все документы по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"по договору 15/24 какие документы были в 2020-07-01..2020-07-31",
|
||||||
|
"по 15/24 дай документы за 2020-07-01..2020-07-31",
|
||||||
|
"какие проведенные документы по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"по договору 15/24 покажи документы за 2020-07-01..2020-07-31",
|
||||||
|
"по договору 15/24 нужна выгрузка документов за 2020-07-01..2020-07-31",
|
||||||
|
"документальный след по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"есть ли документы по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"покажи движения документов по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"что оформляли по договору 15/24 за 2020-07-01..2020-07-31",
|
||||||
|
"все документы, связанные с договором 15/24, за 2020-07-01..2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P0-09",
|
||||||
|
"domain": "drilldown",
|
||||||
|
"intent": "documents_forming_balance",
|
||||||
|
"recipe_id": "address.balance.drilldown_documents",
|
||||||
|
"required_filters": [
|
||||||
|
"account",
|
||||||
|
"as_of_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"counterparty",
|
||||||
|
"contract",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"какие документы формируют остаток по счету 62 на 2020-07-31",
|
||||||
|
"расшифруй остаток по счету 62 на 2020-07-31 по документам",
|
||||||
|
"из каких документов сложился остаток по 62 на 2020-07-31",
|
||||||
|
"покажи документы, которые дали остаток по счету 62 на 2020-07-31",
|
||||||
|
"чем сформирован остаток по 62 на 2020-07-31",
|
||||||
|
"какие документы висят в остатке по 62 на 2020-07-31",
|
||||||
|
"раскрой остаток по 62 на 2020-07-31 до документов",
|
||||||
|
"дай документную расшифровку остатка по счету 62 на 2020-07-31",
|
||||||
|
"по счету 62 какие документы формируют сальдо на 2020-07-31",
|
||||||
|
"покажи первичку, которая сидит в остатке 62 на 2020-07-31",
|
||||||
|
"что конкретно образует остаток по 62 на 2020-07-31",
|
||||||
|
"какие регистраторы формируют остаток счета 62 на 2020-07-31",
|
||||||
|
"разложи остаток по счету 62 на 2020-07-31 на документы",
|
||||||
|
"по 62 на 2020-07-31 нужен drilldown до документов",
|
||||||
|
"остаток счета 62 на 2020-07-31: из чего он состоит",
|
||||||
|
"какие документы попали в хвост по счету 62 на 2020-07-31",
|
||||||
|
"раскрой сальдо 62 на 2020-07-31 по документам",
|
||||||
|
"что формирует баланс по счету 62 на 2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P1-10",
|
||||||
|
"domain": "accounts",
|
||||||
|
"intent": "account_turnover_snapshot",
|
||||||
|
"recipe_id": "address.account.turnover_snapshot",
|
||||||
|
"required_filters": [
|
||||||
|
"account",
|
||||||
|
"period_from",
|
||||||
|
"period_to"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"counterparty",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_SUMMARY",
|
||||||
|
"questions": [
|
||||||
|
"дай обороты по счету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"покажи движение по счету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"какие обороты по 62 были за 2020-07-01..2020-07-31",
|
||||||
|
"выведи дебет/кредит обороты по счету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"сформируй оборотку по счету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"по счету 62 какие обороты в 2020-07-01..2020-07-31",
|
||||||
|
"дай обороты по бухсчету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"что прошло по счету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"обороты по 62 счету за 2020-07-01..2020-07-31",
|
||||||
|
"покажи обороты счета 62 за 2020-07-01..2020-07-31",
|
||||||
|
"какое движение по 62 за 2020-07-01..2020-07-31",
|
||||||
|
"дай сумму оборотов по 62 за 2020-07-01..2020-07-31",
|
||||||
|
"по 62 раскрой дебетовые и кредитовые обороты за 2020-07-01..2020-07-31",
|
||||||
|
"какие суммы крутились по счету 62 за 2020-07-01..2020-07-31",
|
||||||
|
"покажи оборот по плану счетов 62 за 2020-07-01..2020-07-31",
|
||||||
|
"счет 62: движение за 2020-07-01..2020-07-31",
|
||||||
|
"что было по дебету/кредиту счета 62 за 2020-07-01..2020-07-31",
|
||||||
|
"нужны обороты по 62 за 2020-07-01..2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P1-11",
|
||||||
|
"domain": "documents",
|
||||||
|
"intent": "list_documents_by_type",
|
||||||
|
"recipe_id": "address.documents.by_type",
|
||||||
|
"required_filters": [
|
||||||
|
"document_type",
|
||||||
|
"period_from",
|
||||||
|
"period_to"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"organization",
|
||||||
|
"counterparty",
|
||||||
|
"contract",
|
||||||
|
"limit"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"дай документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"покажи документы вида СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"какие документы СписаниеСРасчетногоСчета есть за 2020-07-01..2020-07-31",
|
||||||
|
"выгрузи документы типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"список документов СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"покажи все СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"какие проведенные документы типа СписаниеСРасчетногоСчета были за 2020-07-01..2020-07-31",
|
||||||
|
"документы вида СписаниеСРасчетногоСчета в 2020-07-01..2020-07-31",
|
||||||
|
"по типу СписаниеСРасчетногоСчета покажи документы за 2020-07-01..2020-07-31",
|
||||||
|
"что есть по документам типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"дай выборку по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"покажи реестр СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"по виду документа СписаниеСРасчетногоСчета нужна выгрузка за 2020-07-01..2020-07-31",
|
||||||
|
"какие экземпляры СписаниеСРасчетногоСчета проходили за 2020-07-01..2020-07-31",
|
||||||
|
"документы 1С типа СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"найди документы СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"выведи журнал по СписаниеСРасчетногоСчета за 2020-07-01..2020-07-31",
|
||||||
|
"какие документы этого типа были оформлены за 2020-07-01..2020-07-31"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario_id": "AQ-P1-12",
|
||||||
|
"domain": "document_lookup",
|
||||||
|
"intent": "find_document_by_number_and_date",
|
||||||
|
"recipe_id": "address.document.lookup_by_number_date",
|
||||||
|
"required_filters": [
|
||||||
|
"document_number",
|
||||||
|
"document_date"
|
||||||
|
],
|
||||||
|
"optional_filters": [
|
||||||
|
"document_type",
|
||||||
|
"organization",
|
||||||
|
"counterparty"
|
||||||
|
],
|
||||||
|
"expected_response_type": "FACTUAL_LIST",
|
||||||
|
"questions": [
|
||||||
|
"найди документ № 000123 от 2020-07-15",
|
||||||
|
"покажи документ номер 000123 от 2020-07-15",
|
||||||
|
"есть ли документ 000123 от 2020-07-15",
|
||||||
|
"отыщи документ № 000123 датой 2020-07-15",
|
||||||
|
"нужен документ 000123 от 2020-07-15",
|
||||||
|
"в базе есть документ 000123 от 2020-07-15",
|
||||||
|
"подними документ номер 000123 от 2020-07-15",
|
||||||
|
"покажи, что за документ 000123 от 2020-07-15",
|
||||||
|
"дай карточку документа 000123 от 2020-07-15",
|
||||||
|
"ищу документ 000123 от 2020-07-15",
|
||||||
|
"найди по номеру 000123 и дате 2020-07-15",
|
||||||
|
"проверь наличие документа 000123 от 2020-07-15",
|
||||||
|
"покажи запись по документу 000123 от 2020-07-15",
|
||||||
|
"где документ 000123 от 2020-07-15",
|
||||||
|
"какой документ проходит под номером 000123 от 2020-07-15",
|
||||||
|
"вытащи документ 000123 от 2020-07-15",
|
||||||
|
"документ номер 000123, дата 2020-07-15",
|
||||||
|
"дай документ по реквизитам: номер 000123, дата 2020-07-15"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Resolver & Normalization Spec V2
|
||||||
|
|
||||||
|
Дата: 2026-03-30
|
||||||
|
|
||||||
|
## 1) Общий принцип
|
||||||
|
|
||||||
|
LLM/normalizer не пытается сразу вызвать recipe.
|
||||||
|
Сначала он переводит пользовательскую фразу в canonical intent и нормализованные фильтры.
|
||||||
|
|
||||||
|
Цепочка:
|
||||||
|
`raw_question -> dialect normalization -> intent detection -> filter extraction -> resolver -> recipe selection`
|
||||||
|
|
||||||
|
## 2) Синонимы доменных сигналов
|
||||||
|
|
||||||
|
### Контрагент
|
||||||
|
|
||||||
|
Сигналы:
|
||||||
|
- контрагент
|
||||||
|
- поставщик
|
||||||
|
- клиент
|
||||||
|
- покупатель
|
||||||
|
- контра
|
||||||
|
- организация-партнер
|
||||||
|
|
||||||
|
### Договор
|
||||||
|
|
||||||
|
Сигналы:
|
||||||
|
- договор
|
||||||
|
- контракт
|
||||||
|
- соглашение
|
||||||
|
- договорник
|
||||||
|
- договор с номером
|
||||||
|
|
||||||
|
### Остаток / сальдо
|
||||||
|
|
||||||
|
Сигналы:
|
||||||
|
- остаток
|
||||||
|
- сальдо
|
||||||
|
- что висит
|
||||||
|
- хвост
|
||||||
|
- остаток висит
|
||||||
|
- баланс по счету
|
||||||
|
|
||||||
|
### Дебиторка / кредиторка
|
||||||
|
|
||||||
|
Дебиторка:
|
||||||
|
- кто должен нам
|
||||||
|
- дебиторка
|
||||||
|
- долг нам
|
||||||
|
- задолженность перед нами
|
||||||
|
|
||||||
|
Кредиторка:
|
||||||
|
- кому должны мы
|
||||||
|
- кредиторка
|
||||||
|
- наш долг поставщику
|
||||||
|
- задолженность перед контрагентом
|
||||||
|
|
||||||
|
### Банковый контур
|
||||||
|
|
||||||
|
Сигналы:
|
||||||
|
- банк
|
||||||
|
- платежи
|
||||||
|
- платежки
|
||||||
|
- списания
|
||||||
|
- поступления
|
||||||
|
- движение по расчетному счету
|
||||||
|
|
||||||
|
## 3) Нормализация счетов
|
||||||
|
|
||||||
|
Допустимые формы:
|
||||||
|
- `60`
|
||||||
|
- `62`
|
||||||
|
- `60.01`
|
||||||
|
- `62.01`
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- удалить лишние слова (`счет`, `счёт`, `бухсчет`, `по счету`);
|
||||||
|
- сохранить только валидный account token;
|
||||||
|
- хранить и raw, и normalized форму в debug.
|
||||||
|
|
||||||
|
## 4) Нормализация дат и периодов
|
||||||
|
|
||||||
|
Поддержать:
|
||||||
|
- `на сегодня`
|
||||||
|
- `на текущую дату`
|
||||||
|
- `на конец месяца`
|
||||||
|
- `за июль 2020`
|
||||||
|
- `за период с 01.07.2020 по 31.07.2020`
|
||||||
|
- `за месяц`
|
||||||
|
- `за квартал`
|
||||||
|
|
||||||
|
Выход:
|
||||||
|
- `as_of_date`
|
||||||
|
- либо `period_from + period_to`
|
||||||
|
|
||||||
|
## 5) Resolver policy
|
||||||
|
|
||||||
|
### Counterparty resolver
|
||||||
|
- `name -> candidate set`
|
||||||
|
- exact match > normalized exact > startswith > fuzzy
|
||||||
|
- если ambiguity_count > 1 и нет явного приоритета, вернуть `LIMITED_WITH_REASON`
|
||||||
|
|
||||||
|
### Contract resolver
|
||||||
|
- поддерживать номер договора, имя договора, пару `контрагент + договор`
|
||||||
|
- при наличии обоих якорей резолвить договор в контексте контрагента
|
||||||
|
|
||||||
|
### Document type resolver
|
||||||
|
- белый список русских имен и системных 1С-имен:
|
||||||
|
- `СписаниеСРасчетногоСчета`
|
||||||
|
- `ПоступлениеНаРасчетныйСчет`
|
||||||
|
- и др. по фактическому whitelist
|
||||||
|
|
||||||
|
## 6) Guardrails
|
||||||
|
|
||||||
|
- не допускать свободную генерацию SQL/1С-запросов;
|
||||||
|
- не подменять отсутствующий фильтр догадкой;
|
||||||
|
- не заменять by-contract на by-counterparty молча;
|
||||||
|
- не отдавать пустой factual как будто это найденный результат.
|
||||||
|
|
@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
|
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
|
||||||
const path_1 = __importDefault(require("path"));
|
const path_1 = __importDefault(require("path"));
|
||||||
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
|
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
|
||||||
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
|
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
|
||||||
|
|
@ -46,6 +46,7 @@ exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = toBooleanFlag(process.env.FEATUR
|
||||||
exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1, true);
|
exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1, true);
|
||||||
exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1, false);
|
exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1, false);
|
||||||
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
|
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
|
||||||
|
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, true);
|
||||||
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
|
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
|
||||||
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
|
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
|
||||||
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
|
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,74 @@ const config_1 = require("../config");
|
||||||
const http_1 = require("../utils/http");
|
const http_1 = require("../utils/http");
|
||||||
function buildTestConnectionRouter(client) {
|
function buildTestConnectionRouter(client) {
|
||||||
const router = (0, express_1.Router)();
|
const router = (0, express_1.Router)();
|
||||||
router.post("/api/openai/test-connection", async (req, res, next) => {
|
const handler = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = (req.body ?? {});
|
const body = (req.body ?? {});
|
||||||
|
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
|
||||||
|
const model = String(body.model ?? config_1.DEFAULT_MODEL);
|
||||||
|
const baseUrl = String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL);
|
||||||
|
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
|
||||||
const result = await client.testConnection({
|
const result = await client.testConnection({
|
||||||
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
llmProvider,
|
||||||
model: String(body.model ?? config_1.DEFAULT_MODEL),
|
apiKey,
|
||||||
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
|
model,
|
||||||
|
baseUrl
|
||||||
});
|
});
|
||||||
|
let modelFound = null;
|
||||||
|
let modelsCount = null;
|
||||||
|
if (llmProvider === "local") {
|
||||||
|
try {
|
||||||
|
const models = await client.listModels({
|
||||||
|
llmProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
baseUrl
|
||||||
|
});
|
||||||
|
modelsCount = models.length;
|
||||||
|
modelFound = models.includes(model);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
modelFound = null;
|
||||||
|
modelsCount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
(0, http_1.ok)(res, {
|
(0, http_1.ok)(res, {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
provider: llmProvider,
|
||||||
model: result.model,
|
model: result.model,
|
||||||
|
model_found: modelFound,
|
||||||
|
models_count: modelsCount,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const listModelsHandler = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = (req.body ?? {});
|
||||||
|
const models = await client.listModels({
|
||||||
|
llmProvider: body.llmProvider === "local" ? "local" : "openai",
|
||||||
|
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||||
|
model: String(body.model ?? config_1.DEFAULT_MODEL),
|
||||||
|
baseUrl: String(body.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL)
|
||||||
});
|
});
|
||||||
|
(0, http_1.ok)(res, {
|
||||||
|
ok: true,
|
||||||
|
models,
|
||||||
|
count: models.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
router.post("/api/llm/test-connection", handler);
|
||||||
|
router.post("/api/llm/models", listModelsHandler);
|
||||||
|
// Backward-compatible route for old frontend builds.
|
||||||
|
router.post("/api/openai/test-connection", handler);
|
||||||
|
router.post("/api/openai/models", listModelsHandler);
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.extractAddressFilters = extractAddressFilters;
|
exports.extractAddressFilters = extractAddressFilters;
|
||||||
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
||||||
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
|
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
|
||||||
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
|
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
|
||||||
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
||||||
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
|
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
|
||||||
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
|
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
|
||||||
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||||
const PERIOD_RANGE_PATTERN_2 = /(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
const PERIOD_RANGE_PATTERN_2 = /(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||||
|
const YEAR_RANGE_PATTERN = /(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
|
||||||
|
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|до|to|по)\s*\b(20\d{2})\b/iu;
|
||||||
|
const YEAR_PERIOD_PATTERN = /(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
|
||||||
|
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
|
||||||
|
const YEAR_PERIOD_ANY_PATTERN = /(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
|
||||||
|
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
|
||||||
|
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
|
||||||
function toIsoDate(year, month, day) {
|
function toIsoDate(year, month, day) {
|
||||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -61,6 +68,64 @@ function parseDateToken(token) {
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
function resolveMonthByName(rawMonthName) {
|
||||||
|
const token = String(rawMonthName ?? "").trim().toLowerCase();
|
||||||
|
if (!token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (/^янв|^january|^jan/.test(token))
|
||||||
|
return 1;
|
||||||
|
if (/^фев|^february|^feb/.test(token))
|
||||||
|
return 2;
|
||||||
|
if (/^мар|^march|^mar/.test(token))
|
||||||
|
return 3;
|
||||||
|
if (/^апр|^april|^apr/.test(token))
|
||||||
|
return 4;
|
||||||
|
if (/^ма[йя]|^may/.test(token))
|
||||||
|
return 5;
|
||||||
|
if (/^июн|^june|^jun/.test(token))
|
||||||
|
return 6;
|
||||||
|
if (/^июл|^july|^jul/.test(token))
|
||||||
|
return 7;
|
||||||
|
if (/^авг|^august|^aug/.test(token))
|
||||||
|
return 8;
|
||||||
|
if (/^сен|^сент|^september|^sep/.test(token))
|
||||||
|
return 9;
|
||||||
|
if (/^окт|^october|^oct/.test(token))
|
||||||
|
return 10;
|
||||||
|
if (/^ноя|^november|^nov/.test(token))
|
||||||
|
return 11;
|
||||||
|
if (/^дек|^december|^dec/.test(token))
|
||||||
|
return 12;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function extractMonthPeriod(text) {
|
||||||
|
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
|
||||||
|
if (numericMatch) {
|
||||||
|
const month = Number(numericMatch[1]);
|
||||||
|
const year = Number(numericMatch[2]);
|
||||||
|
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
|
||||||
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
return {
|
||||||
|
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
|
||||||
|
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
|
||||||
|
if (byNameMatch) {
|
||||||
|
const month = resolveMonthByName(String(byNameMatch[1]));
|
||||||
|
const year = Number(byNameMatch[2]);
|
||||||
|
if (month && year >= 2000 && year <= 2099) {
|
||||||
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
return {
|
||||||
|
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
|
||||||
|
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
function extractPeriodRange(text) {
|
function extractPeriodRange(text) {
|
||||||
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
||||||
if (!directMatch) {
|
if (!directMatch) {
|
||||||
|
|
@ -73,6 +138,64 @@ function extractPeriodRange(text) {
|
||||||
...(periodTo ? { period_to: periodTo } : {})
|
...(periodTo ? { period_to: periodTo } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function extractYearPeriod(text) {
|
||||||
|
const match = text.match(YEAR_PERIOD_PATTERN);
|
||||||
|
if (match) {
|
||||||
|
const year = Number(match[1]);
|
||||||
|
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
period_from: `${year}-01-01`,
|
||||||
|
period_to: `${year}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
|
||||||
|
if (relaxedYearMatch) {
|
||||||
|
const year = Number(relaxedYearMatch[1]);
|
||||||
|
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
|
||||||
|
return {
|
||||||
|
period_from: `${year}-01-01`,
|
||||||
|
period_to: `${year}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
|
||||||
|
if (!shortYearMatch) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const shortYear = Number(shortYearMatch[1]);
|
||||||
|
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const year = 2000 + shortYear;
|
||||||
|
return {
|
||||||
|
period_from: `${year}-01-01`,
|
||||||
|
period_to: `${year}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function extractYearRangePeriod(text) {
|
||||||
|
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
|
||||||
|
if (!match) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const leftYear = Number(match[1]);
|
||||||
|
const rightYear = Number(match[2]);
|
||||||
|
if (!Number.isFinite(leftYear) ||
|
||||||
|
!Number.isFinite(rightYear) ||
|
||||||
|
leftYear < 2000 ||
|
||||||
|
leftYear > 2099 ||
|
||||||
|
rightYear < 2000 ||
|
||||||
|
rightYear > 2099) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const fromYear = Math.min(leftYear, rightYear);
|
||||||
|
const toYear = Math.max(leftYear, rightYear);
|
||||||
|
return {
|
||||||
|
period_from: `${fromYear}-01-01`,
|
||||||
|
period_to: `${toYear}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
function cleanupAnchorValue(value) {
|
function cleanupAnchorValue(value) {
|
||||||
const normalized = String(value ?? "").trim();
|
const normalized = String(value ?? "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|
@ -84,11 +207,11 @@ function cleanupAnchorValue(value) {
|
||||||
if (periodTailPattern.test(normalized)) {
|
if (periodTailPattern.test(normalized)) {
|
||||||
return normalized.replace(periodTailPattern, "").trim();
|
return normalized.replace(periodTailPattern, "").trim();
|
||||||
}
|
}
|
||||||
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
|
const allTimeTailPattern = /\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
|
||||||
if (allTimeTailPattern.test(normalized)) {
|
if (allTimeTailPattern.test(normalized)) {
|
||||||
return normalized.replace(allTimeTailPattern, "").trim();
|
return normalized.replace(allTimeTailPattern, "").trim();
|
||||||
}
|
}
|
||||||
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
|
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
|
||||||
if (allTimeTailPatternEn.test(normalized)) {
|
if (allTimeTailPatternEn.test(normalized)) {
|
||||||
return normalized.replace(allTimeTailPatternEn, "").trim();
|
return normalized.replace(allTimeTailPatternEn, "").trim();
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +222,186 @@ function cleanupAnchorValue(value) {
|
||||||
}
|
}
|
||||||
function hasAllTimeHint(text) {
|
function hasAllTimeHint(text) {
|
||||||
const value = String(text ?? "");
|
const value = String(text ?? "");
|
||||||
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
|
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
|
||||||
|
}
|
||||||
|
function extractLooseByAnchorValue(text) {
|
||||||
|
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const token = String(match[1] ?? "").trim();
|
||||||
|
if (!token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
const stopWords = new Set([
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"контре",
|
||||||
|
"компании",
|
||||||
|
"компанию",
|
||||||
|
"организации",
|
||||||
|
"организацию",
|
||||||
|
"поставщику",
|
||||||
|
"поставщика",
|
||||||
|
"клиенту",
|
||||||
|
"клиента",
|
||||||
|
"покупателю",
|
||||||
|
"покупателя",
|
||||||
|
"партнеру",
|
||||||
|
"партнера",
|
||||||
|
"договору",
|
||||||
|
"договора",
|
||||||
|
"счету",
|
||||||
|
"счёту",
|
||||||
|
"дате",
|
||||||
|
"периоду",
|
||||||
|
"период",
|
||||||
|
"документам",
|
||||||
|
"докам",
|
||||||
|
"взаиморасчетам",
|
||||||
|
"взаиморасчётам"
|
||||||
|
]);
|
||||||
|
if (stopWords.has(lowered)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
function isLikelyCounterpartyToken(rawToken) {
|
||||||
|
const token = String(rawToken ?? "").trim();
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (!token || token.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(lowered)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(?:19|20)\d{2}$/.test(lowered)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"за",
|
||||||
|
"с",
|
||||||
|
"по",
|
||||||
|
"на",
|
||||||
|
"и",
|
||||||
|
"или",
|
||||||
|
"док",
|
||||||
|
"доки",
|
||||||
|
"документ",
|
||||||
|
"документы",
|
||||||
|
"документов",
|
||||||
|
"банк",
|
||||||
|
"банковские",
|
||||||
|
"операции",
|
||||||
|
"платежи",
|
||||||
|
"платеж",
|
||||||
|
"платёж",
|
||||||
|
"контрагент",
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"компания",
|
||||||
|
"компании",
|
||||||
|
"организация",
|
||||||
|
"организации",
|
||||||
|
"год",
|
||||||
|
"года",
|
||||||
|
"г",
|
||||||
|
"плс",
|
||||||
|
"pls",
|
||||||
|
"пж",
|
||||||
|
"пжлст",
|
||||||
|
"пожалуйста",
|
||||||
|
"бля",
|
||||||
|
"блять",
|
||||||
|
"епт",
|
||||||
|
"ёпт",
|
||||||
|
"епта",
|
||||||
|
"нах",
|
||||||
|
"нахуй",
|
||||||
|
"покеж",
|
||||||
|
"покажи",
|
||||||
|
"выведи"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(lowered);
|
||||||
|
}
|
||||||
|
function hasDocsOrBankSignal(text) {
|
||||||
|
const lowered = String(text ?? "").toLowerCase();
|
||||||
|
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(lowered);
|
||||||
|
}
|
||||||
|
function extractCounterpartyFromFreeTextHeuristic(text) {
|
||||||
|
if (!hasDocsOrBankSignal(text)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const tokens = String(text ?? "")
|
||||||
|
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const monthTokens = [
|
||||||
|
"янв",
|
||||||
|
"фев",
|
||||||
|
"мар",
|
||||||
|
"апр",
|
||||||
|
"май",
|
||||||
|
"июн",
|
||||||
|
"июл",
|
||||||
|
"авг",
|
||||||
|
"сен",
|
||||||
|
"сент",
|
||||||
|
"окт",
|
||||||
|
"ноя",
|
||||||
|
"дек",
|
||||||
|
"january",
|
||||||
|
"february",
|
||||||
|
"march",
|
||||||
|
"april",
|
||||||
|
"may",
|
||||||
|
"june",
|
||||||
|
"july",
|
||||||
|
"august",
|
||||||
|
"september",
|
||||||
|
"october",
|
||||||
|
"november",
|
||||||
|
"december"
|
||||||
|
];
|
||||||
|
for (const token of tokens) {
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (!isLikelyCounterpartyToken(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function extractImplicitCounterpartyValue(text) {
|
||||||
|
const input = String(text ?? "");
|
||||||
|
const beforeDocsMatch = input.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (beforeDocsMatch) {
|
||||||
|
const candidate = String(beforeDocsMatch[1] ?? "").trim();
|
||||||
|
if (isLikelyCounterpartyToken(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const afterDocsMatch = input.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (afterDocsMatch) {
|
||||||
|
const candidate = String(afterDocsMatch[1] ?? "").trim();
|
||||||
|
if (isLikelyCounterpartyToken(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
function shiftDaysIso(baseIso, deltaDays) {
|
function shiftDaysIso(baseIso, deltaDays) {
|
||||||
const date = new Date(`${baseIso}T00:00:00.000Z`);
|
const date = new Date(`${baseIso}T00:00:00.000Z`);
|
||||||
|
|
@ -137,6 +439,27 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
if (counterpartyMatch) {
|
if (counterpartyMatch) {
|
||||||
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
||||||
}
|
}
|
||||||
|
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||||
|
const fallbackCounterparty = extractLooseByAnchorValue(text);
|
||||||
|
if (fallbackCounterparty) {
|
||||||
|
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
|
||||||
|
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||||
|
const implicitCounterparty = extractImplicitCounterpartyValue(text);
|
||||||
|
if (implicitCounterparty) {
|
||||||
|
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
|
||||||
|
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||||
|
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
|
||||||
|
if (heuristicCounterparty) {
|
||||||
|
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
|
||||||
|
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
|
||||||
|
}
|
||||||
|
}
|
||||||
const contractMatch = text.match(CONTRACT_PATTERN);
|
const contractMatch = text.match(CONTRACT_PATTERN);
|
||||||
if (contractMatch) {
|
if (contractMatch) {
|
||||||
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
|
filters.contract = cleanupAnchorValue(String(contractMatch[1]));
|
||||||
|
|
@ -148,6 +471,30 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
if (periodRange.period_to) {
|
if (periodRange.period_to) {
|
||||||
filters.period_to = periodRange.period_to;
|
filters.period_to = periodRange.period_to;
|
||||||
}
|
}
|
||||||
|
if (!filters.period_from && !filters.period_to) {
|
||||||
|
const monthPeriod = extractMonthPeriod(text);
|
||||||
|
if (monthPeriod.period_from && monthPeriod.period_to) {
|
||||||
|
filters.period_from = monthPeriod.period_from;
|
||||||
|
filters.period_to = monthPeriod.period_to;
|
||||||
|
warnings.push("period_derived_from_month_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filters.period_from && !filters.period_to) {
|
||||||
|
const yearRangePeriod = extractYearRangePeriod(text);
|
||||||
|
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
|
||||||
|
filters.period_from = yearRangePeriod.period_from;
|
||||||
|
filters.period_to = yearRangePeriod.period_to;
|
||||||
|
warnings.push("period_derived_from_year_range_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filters.period_from && !filters.period_to) {
|
||||||
|
const yearPeriod = extractYearPeriod(text);
|
||||||
|
if (yearPeriod.period_from && yearPeriod.period_to) {
|
||||||
|
filters.period_from = yearPeriod.period_from;
|
||||||
|
filters.period_to = yearPeriod.period_to;
|
||||||
|
warnings.push("period_derived_from_year_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
|
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
|
||||||
if (!filters.period_from && !filters.period_to) {
|
if (!filters.period_from && !filters.period_to) {
|
||||||
const asOfDate = extractAsOfDate(text);
|
const asOfDate = extractAsOfDate(text);
|
||||||
|
|
|
||||||
|
|
@ -62,23 +62,201 @@ const OPEN_ITEMS_HINTS = [
|
||||||
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
|
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
|
||||||
"documents by counterparty",
|
"documents by counterparty",
|
||||||
"docs by counterparty",
|
"docs by counterparty",
|
||||||
|
"documents by company",
|
||||||
|
"documents by supplier",
|
||||||
|
"documents by customer",
|
||||||
|
"documents by client",
|
||||||
|
"documents by partner",
|
||||||
"show documents by counterparty",
|
"show documents by counterparty",
|
||||||
"list documents by counterparty",
|
"list documents by counterparty",
|
||||||
|
"документы по",
|
||||||
|
"доступные документы",
|
||||||
|
"список документов",
|
||||||
"документ",
|
"документ",
|
||||||
|
"доки",
|
||||||
|
"доки по",
|
||||||
|
"док по",
|
||||||
"по контрагент"
|
"по контрагент"
|
||||||
];
|
];
|
||||||
const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
|
const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
|
||||||
"bank operations by counterparty",
|
"bank operations by counterparty",
|
||||||
"bank payments by counterparty",
|
"bank payments by counterparty",
|
||||||
"payment orders by counterparty",
|
"payment orders by counterparty",
|
||||||
|
"bank operations by company",
|
||||||
|
"bank operations by supplier",
|
||||||
|
"bank operations by customer",
|
||||||
"show bank operations by counterparty",
|
"show bank operations by counterparty",
|
||||||
|
"bank ops",
|
||||||
|
"transactions by counterparty",
|
||||||
"банков",
|
"банков",
|
||||||
"выписк",
|
"выписк",
|
||||||
"платеж"
|
"платеж",
|
||||||
|
"платёж",
|
||||||
|
"оплат",
|
||||||
|
"списан",
|
||||||
|
"поступлен",
|
||||||
|
"движени"
|
||||||
];
|
];
|
||||||
function hasAny(text, patterns) {
|
function hasAny(text, patterns) {
|
||||||
return patterns.some((item) => text.includes(item));
|
return patterns.some((item) => text.includes(item));
|
||||||
}
|
}
|
||||||
|
function isLikelyCounterpartyToken(rawToken) {
|
||||||
|
const token = String(rawToken ?? "").trim().toLowerCase();
|
||||||
|
if (!token || token.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(?:19|20)\d{2}$/.test(token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"за",
|
||||||
|
"с",
|
||||||
|
"по",
|
||||||
|
"на",
|
||||||
|
"и",
|
||||||
|
"или",
|
||||||
|
"док",
|
||||||
|
"доки",
|
||||||
|
"доки?",
|
||||||
|
"документ",
|
||||||
|
"документы",
|
||||||
|
"документов",
|
||||||
|
"банк",
|
||||||
|
"банковские",
|
||||||
|
"операции",
|
||||||
|
"платежи",
|
||||||
|
"платеж",
|
||||||
|
"платёж",
|
||||||
|
"контрагент",
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"компания",
|
||||||
|
"компании",
|
||||||
|
"организация",
|
||||||
|
"организации",
|
||||||
|
"год",
|
||||||
|
"года",
|
||||||
|
"г",
|
||||||
|
"плс",
|
||||||
|
"pls",
|
||||||
|
"пж",
|
||||||
|
"пжлст",
|
||||||
|
"пожалуйста",
|
||||||
|
"бля",
|
||||||
|
"блять",
|
||||||
|
"епт",
|
||||||
|
"ёпт",
|
||||||
|
"епта",
|
||||||
|
"нах",
|
||||||
|
"нахуй"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(token);
|
||||||
|
}
|
||||||
|
function hasPartyAnchorMention(text) {
|
||||||
|
return (text.includes("контраг") ||
|
||||||
|
text.includes("контра") ||
|
||||||
|
text.includes("counterparty") ||
|
||||||
|
text.includes("компан") ||
|
||||||
|
text.includes("company") ||
|
||||||
|
text.includes("организац") ||
|
||||||
|
text.includes("supplier") ||
|
||||||
|
text.includes("vendor") ||
|
||||||
|
text.includes("customer") ||
|
||||||
|
text.includes("client") ||
|
||||||
|
text.includes("partner") ||
|
||||||
|
text.includes("поставщик") ||
|
||||||
|
text.includes("клиент") ||
|
||||||
|
text.includes("покупател") ||
|
||||||
|
text.includes("партнер"));
|
||||||
|
}
|
||||||
|
function hasLooseByAnchorMention(text) {
|
||||||
|
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const token = String(match[1] ?? "").toLowerCase();
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"контре",
|
||||||
|
"компании",
|
||||||
|
"компанию",
|
||||||
|
"организации",
|
||||||
|
"организацию",
|
||||||
|
"поставщику",
|
||||||
|
"поставщика",
|
||||||
|
"клиенту",
|
||||||
|
"клиента",
|
||||||
|
"покупателю",
|
||||||
|
"покупателя",
|
||||||
|
"партнеру",
|
||||||
|
"партнера",
|
||||||
|
"договору",
|
||||||
|
"договора",
|
||||||
|
"счету",
|
||||||
|
"счёту",
|
||||||
|
"дате",
|
||||||
|
"периоду",
|
||||||
|
"период",
|
||||||
|
"документам",
|
||||||
|
"докам"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(token);
|
||||||
|
}
|
||||||
|
function hasImplicitCounterpartyAnchorAroundDocs(text) {
|
||||||
|
const beforeDocsMatch = text.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const afterDocsMatch = text.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function hasDocsOrBankSignal(text) {
|
||||||
|
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);
|
||||||
|
}
|
||||||
|
function hasHeuristicCounterpartyAnchor(text) {
|
||||||
|
if (!hasDocsOrBankSignal(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tokens = String(text ?? "")
|
||||||
|
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
for (const token of tokens) {
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (!isLikelyCounterpartyToken(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function hasGenericAddressLookupSignal(text) {
|
||||||
|
return (/\bесть\b/iu.test(text) ||
|
||||||
|
/\bпокажи\b/iu.test(text) ||
|
||||||
|
/\bвыведи\b/iu.test(text) ||
|
||||||
|
/\bкакие\b/iu.test(text) ||
|
||||||
|
/\bчто(?:-|\s)?то\b/iu.test(text) ||
|
||||||
|
/за\s+любой\s+период/iu.test(text) ||
|
||||||
|
/за\s+вс[её]\s+время/iu.test(text) ||
|
||||||
|
/for\s+all\s+time/iu.test(text) ||
|
||||||
|
/all\s+time/iu.test(text));
|
||||||
|
}
|
||||||
function hasAccountNumberAnchor(text) {
|
function hasAccountNumberAnchor(text) {
|
||||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +291,7 @@ function resolveAddressIntent(userMessage) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
|
if (hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
|
||||||
(text.includes("контраг") || text.includes("counterparty"))) {
|
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))) {
|
||||||
return {
|
return {
|
||||||
intent: "bank_operations_by_counterparty",
|
intent: "bank_operations_by_counterparty",
|
||||||
confidence: "medium",
|
confidence: "medium",
|
||||||
|
|
@ -121,13 +299,23 @@ function resolveAddressIntent(userMessage) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
if (hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
||||||
(text.includes("контраг") || text.includes("counterparty"))) {
|
(hasPartyAnchorMention(text) ||
|
||||||
|
hasLooseByAnchorMention(text) ||
|
||||||
|
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||||
|
hasHeuristicCounterpartyAnchor(text))) {
|
||||||
return {
|
return {
|
||||||
intent: "list_documents_by_counterparty",
|
intent: "list_documents_by_counterparty",
|
||||||
confidence: "medium",
|
confidence: "medium",
|
||||||
reasons: ["documents_by_counterparty_signal_detected"]
|
reasons: ["documents_by_counterparty_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "list_documents_by_counterparty",
|
||||||
|
confidence: "low",
|
||||||
|
reasons: ["generic_lookup_with_loose_anchor_fallback"]
|
||||||
|
};
|
||||||
|
}
|
||||||
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
|
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
|
||||||
return {
|
return {
|
||||||
intent: "open_items_by_counterparty_or_contract",
|
intent: "open_items_by_counterparty_or_contract",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.executeAddressMcpQuery = executeAddressMcpQuery;
|
exports.executeAddressMcpQuery = executeAddressMcpQuery;
|
||||||
const config_1 = require("../config");
|
const config_1 = require("../config");
|
||||||
|
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
||||||
function toStringValue(value) {
|
function toStringValue(value) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -20,8 +24,76 @@ function parseFiniteNumber(value) {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
function textMojibakeScore(value) {
|
||||||
|
const source = String(value ?? "");
|
||||||
|
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
|
||||||
|
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
|
||||||
|
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
|
||||||
|
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
|
||||||
|
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
|
||||||
|
}
|
||||||
|
function looksLikeMojibake(value) {
|
||||||
|
const source = String(value ?? "");
|
||||||
|
if (!source.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
|
||||||
|
}
|
||||||
|
function decodeUtf8FromWin1251Mojibake(value) {
|
||||||
|
if (!looksLikeMojibake(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bytes = iconv_lite_1.default.encode(value, "win1251");
|
||||||
|
const decoded = bytes.toString("utf8");
|
||||||
|
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function decodeUtf8FromLatin1Mojibake(value) {
|
||||||
|
if (!looksLikeMojibake(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(value, "latin1").toString("utf8");
|
||||||
|
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeMojibakeString(value) {
|
||||||
|
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
|
||||||
|
return decodeUtf8FromLatin1Mojibake(fromWin1251);
|
||||||
|
}
|
||||||
|
function normalizeMojibakeValue(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return normalizeMojibakeString(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeMojibakeValue(item));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const source = value;
|
||||||
|
const normalized = {};
|
||||||
|
for (const [key, raw] of Object.entries(source)) {
|
||||||
|
const repairedKey = normalizeMojibakeString(key);
|
||||||
|
normalized[repairedKey] = normalizeMojibakeValue(raw);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function normalizeMojibakeRows(rows) {
|
||||||
|
return rows.map((row) => normalizeMojibakeValue(row));
|
||||||
|
}
|
||||||
function parseRowsFromTextTable(source) {
|
function parseRowsFromTextTable(source) {
|
||||||
const normalized = String(source ?? "").replace(/\r/g, "").trim();
|
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +163,7 @@ function parseRowsFromTextTable(source) {
|
||||||
row.Amount = parseFiniteNumber(values[4]) ?? values[4];
|
row.Amount = parseFiniteNumber(values[4]) ?? values[4];
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
return rows;
|
return normalizeMojibakeRows(rows);
|
||||||
}
|
}
|
||||||
function parseExecutePayload(payload) {
|
function parseExecutePayload(payload) {
|
||||||
if (!payload || typeof payload !== "object") {
|
if (!payload || typeof payload !== "object") {
|
||||||
|
|
@ -110,9 +182,9 @@ function parseExecutePayload(payload) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Array.isArray(source.data)) {
|
if (Array.isArray(source.data)) {
|
||||||
const rows = source.data
|
const rows = normalizeMojibakeRows(source.data
|
||||||
.map((item) => (item && typeof item === "object" ? item : null))
|
.map((item) => (item && typeof item === "object" ? item : null))
|
||||||
.filter((item) => item !== null);
|
.filter((item) => item !== null));
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -127,9 +199,9 @@ function parseExecutePayload(payload) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (source.data && typeof source.data === "object" && Array.isArray(source.data.rows)) {
|
if (source.data && typeof source.data === "object" && Array.isArray(source.data.rows)) {
|
||||||
const rows = (source.data.rows ?? [])
|
const rows = normalizeMojibakeRows((source.data.rows ?? [])
|
||||||
.map((item) => (item && typeof item === "object" ? item : null))
|
.map((item) => (item && typeof item === "object" ? item : null))
|
||||||
.filter((item) => item !== null);
|
.filter((item) => item !== null));
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rows,
|
rows,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
|
||||||
const ADDRESS_ENTITY_TOKENS = [
|
const ADDRESS_ENTITY_TOKENS = [
|
||||||
"counterparty",
|
"counterparty",
|
||||||
"counterparties",
|
"counterparties",
|
||||||
|
"company",
|
||||||
|
"organization",
|
||||||
|
"supplier",
|
||||||
|
"vendor",
|
||||||
|
"customer",
|
||||||
|
"client",
|
||||||
|
"partner",
|
||||||
"contract",
|
"contract",
|
||||||
"contracts",
|
"contracts",
|
||||||
"account",
|
"account",
|
||||||
|
|
@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||||
"owes",
|
"owes",
|
||||||
"owed",
|
"owed",
|
||||||
"контрагент",
|
"контрагент",
|
||||||
|
"контра",
|
||||||
|
"компан",
|
||||||
|
"организац",
|
||||||
|
"поставщик",
|
||||||
|
"клиент",
|
||||||
|
"покупател",
|
||||||
|
"партнер",
|
||||||
|
"банк",
|
||||||
|
"выписк",
|
||||||
|
"операц",
|
||||||
"договор",
|
"договор",
|
||||||
"счет",
|
"счет",
|
||||||
"счёт",
|
"счёт",
|
||||||
"документ",
|
"документ",
|
||||||
|
"доки",
|
||||||
|
"док",
|
||||||
"остаток",
|
"остаток",
|
||||||
"дебитор",
|
"дебитор",
|
||||||
"кредитор",
|
"кредитор",
|
||||||
|
|
@ -71,6 +90,54 @@ const DEEP_REASONING_TOKENS = [
|
||||||
"разрыв",
|
"разрыв",
|
||||||
"ошибк"
|
"ошибк"
|
||||||
];
|
];
|
||||||
|
function hasLooseByAnchorMention(text) {
|
||||||
|
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const token = String(match[1] ?? "").toLowerCase();
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"контре",
|
||||||
|
"компании",
|
||||||
|
"компанию",
|
||||||
|
"организации",
|
||||||
|
"организацию",
|
||||||
|
"поставщику",
|
||||||
|
"поставщика",
|
||||||
|
"клиенту",
|
||||||
|
"клиента",
|
||||||
|
"покупателю",
|
||||||
|
"покупателя",
|
||||||
|
"партнеру",
|
||||||
|
"партнера",
|
||||||
|
"договору",
|
||||||
|
"договора",
|
||||||
|
"счету",
|
||||||
|
"счёту",
|
||||||
|
"дате",
|
||||||
|
"периоду",
|
||||||
|
"период",
|
||||||
|
"документам",
|
||||||
|
"докам",
|
||||||
|
"взаиморасчетам",
|
||||||
|
"взаиморасчётам"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(token);
|
||||||
|
}
|
||||||
|
function hasAddressFollowupSignal(text) {
|
||||||
|
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function hasAnyToken(text, tokens) {
|
function hasAnyToken(text, tokens) {
|
||||||
return tokens.some((token) => text.includes(token));
|
return tokens.some((token) => text.includes(token));
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +153,8 @@ function detectAddressQuestionMode(userMessage) {
|
||||||
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
|
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
|
||||||
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
|
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
|
||||||
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
|
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
|
||||||
|
const hasLooseByAnchor = hasLooseByAnchorMention(text);
|
||||||
|
const hasFollowupSignal = hasAddressFollowupSignal(text);
|
||||||
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
|
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
|
||||||
return {
|
return {
|
||||||
mode: "address_query",
|
mode: "address_query",
|
||||||
|
|
@ -93,6 +162,13 @@ function detectAddressQuestionMode(userMessage) {
|
||||||
reasons: ["address_action_detected", "address_entity_detected"]
|
reasons: ["address_action_detected", "address_entity_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
|
||||||
|
return {
|
||||||
|
mode: "address_query",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
|
||||||
|
};
|
||||||
|
}
|
||||||
if (hasAddressEntity && !hasDeepReasoning) {
|
if (hasAddressEntity && !hasDeepReasoning) {
|
||||||
return {
|
return {
|
||||||
mode: "address_query",
|
mode: "address_query",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.AddressQueryService = void 0;
|
exports.AddressQueryService = void 0;
|
||||||
const config_1 = require("../config");
|
const config_1 = require("../config");
|
||||||
const addressQueryClassifier_1 = require("./addressQueryClassifier");
|
|
||||||
const addressQueryShapeClassifier_1 = require("./addressQueryShapeClassifier");
|
|
||||||
const addressIntentResolver_1 = require("./addressIntentResolver");
|
|
||||||
const addressFilterExtractor_1 = require("./addressFilterExtractor");
|
|
||||||
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
|
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
|
||||||
const addressMcpClient_1 = require("./addressMcpClient");
|
const addressMcpClient_1 = require("./addressMcpClient");
|
||||||
|
const decomposeStage_1 = require("./address_runtime/decomposeStage");
|
||||||
|
const resolveStage_1 = require("./address_runtime/resolveStage");
|
||||||
|
const composeStage_1 = require("./address_runtime/composeStage");
|
||||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
|
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
|
||||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
|
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
|
||||||
const PARTY_ANCHOR_STOPWORDS = new Set([
|
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||||
|
|
@ -323,20 +322,56 @@ function applyIntentSpecificFilter(intent, rows) {
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
function formatTopRows(rows, limit = 6) {
|
function hasExplicitPeriodWindow(filters) {
|
||||||
return rows.slice(0, limit).map((row, index) => {
|
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||||
const period = row.period ?? "дата не указана";
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
|
||||||
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
|
||||||
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
|
||||||
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
|
||||||
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function inferReplyType(responseType) {
|
function canAutoBroadenPeriodWindow(intent, filters) {
|
||||||
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
if (!hasExplicitPeriodWindow(filters)) {
|
||||||
return "factual";
|
return false;
|
||||||
}
|
}
|
||||||
return "partial_coverage";
|
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
|
||||||
|
}
|
||||||
|
function toIsoDatePrefix(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function deriveObservedPeriodWindow(rows) {
|
||||||
|
const dates = rows
|
||||||
|
.map((row) => toIsoDatePrefix(row.period))
|
||||||
|
.filter((item) => Boolean(item))
|
||||||
|
.sort();
|
||||||
|
if (dates.length === 0) {
|
||||||
|
return {
|
||||||
|
period_from: null,
|
||||||
|
period_to: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
period_from: dates[0],
|
||||||
|
period_to: dates[dates.length - 1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function composeAutoBroadenedPeriodPrefix(requested, observed) {
|
||||||
|
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
|
||||||
|
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
|
||||||
|
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
|
||||||
|
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
|
||||||
|
}
|
||||||
|
if (requestedFrom && requestedTo) {
|
||||||
|
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
|
||||||
|
}
|
||||||
|
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
|
||||||
}
|
}
|
||||||
function runtimeReadinessForLimitedCategory(category) {
|
function runtimeReadinessForLimitedCategory(category) {
|
||||||
if (category === "empty_match" || category === "missing_anchor") {
|
if (category === "empty_match" || category === "missing_anchor") {
|
||||||
|
|
@ -449,90 +484,6 @@ function toLegacyMcpStatus(status) {
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
function resolvePrimaryAnchor(intent, filters) {
|
|
||||||
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
|
||||||
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
|
||||||
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
|
||||||
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
|
||||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
|
||||||
if (account) {
|
|
||||||
return {
|
|
||||||
anchor_type: "account",
|
|
||||||
anchor_value_raw: account,
|
|
||||||
anchor_value_resolved: account,
|
|
||||||
resolver_confidence: "high",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (counterparty) {
|
|
||||||
return {
|
|
||||||
anchor_type: "counterparty",
|
|
||||||
anchor_value_raw: counterparty,
|
|
||||||
anchor_value_resolved: counterparty,
|
|
||||||
resolver_confidence: "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (contract) {
|
|
||||||
return {
|
|
||||||
anchor_type: "contract",
|
|
||||||
anchor_value_raw: contract,
|
|
||||||
anchor_value_resolved: contract,
|
|
||||||
resolver_confidence: "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (documentRef) {
|
|
||||||
return {
|
|
||||||
anchor_type: "document_ref",
|
|
||||||
anchor_value_raw: documentRef,
|
|
||||||
anchor_value_resolved: documentRef,
|
|
||||||
resolver_confidence: "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
anchor_type: "unknown",
|
|
||||||
anchor_value_raw: null,
|
|
||||||
anchor_value_resolved: null,
|
|
||||||
resolver_confidence: "low",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function refineAnchorFromRows(anchor, rows) {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
|
||||||
if (!needleRaw) {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
const candidates = uniqueStrings(rows
|
|
||||||
.flatMap((row) => row.analytics)
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
if (candidates.length === 1) {
|
|
||||||
return {
|
|
||||||
...anchor,
|
|
||||||
anchor_value_resolved: candidates[0],
|
|
||||||
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...anchor,
|
|
||||||
anchor_value_resolved: candidates[0],
|
|
||||||
resolver_confidence: "low",
|
|
||||||
ambiguity_count: candidates.length - 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function composeLimitedReply(category, reason, nextStep) {
|
function composeLimitedReply(category, reason, nextStep) {
|
||||||
const heading = category === "empty_match"
|
const heading = category === "empty_match"
|
||||||
? "В live-данных по текущему фильтру записи не найдены."
|
? "В live-данных по текущему фильтру записи не найдены."
|
||||||
|
|
@ -601,124 +552,19 @@ function buildLimitedExecutionResult(input) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function contractCandidatesFromRows(rows) {
|
|
||||||
const candidates = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
for (const token of [row.registrator, ...row.analytics]) {
|
|
||||||
const normalized = token.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/договор|contract|дог\./i.test(normalized)) {
|
|
||||||
candidates.push(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uniqueStrings(candidates);
|
|
||||||
}
|
|
||||||
function composeFactualReply(intent, rows) {
|
|
||||||
if (intent === "account_balance_snapshot") {
|
|
||||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
|
||||||
const lines = [
|
|
||||||
"Адресный срез по счету собран (по движениям live MCP).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
|
||||||
...formatTopRows(rows, 4)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_SUMMARY",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (intent === "documents_forming_balance") {
|
|
||||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
|
||||||
const lines = [
|
|
||||||
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
|
||||||
`Документных строк отобрано: ${rows.length}.`,
|
|
||||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
|
||||||
...formatTopRows(rows, 8),
|
|
||||||
"Можно уточнить выборку по контрагенту, договору или периоду."
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (intent === "list_open_contracts") {
|
|
||||||
const contracts = contractCandidatesFromRows(rows);
|
|
||||||
const lines = [
|
|
||||||
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
|
||||||
`Строк движения: ${rows.length}.`,
|
|
||||||
`Договорных кандидатов: ${contracts.length}.`
|
|
||||||
];
|
|
||||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (intent === "open_items_by_counterparty_or_contract") {
|
|
||||||
const lines = [
|
|
||||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
...formatTopRows(rows, 6)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (intent === "list_documents_by_counterparty") {
|
|
||||||
const lines = [
|
|
||||||
"Собран список документов по контрагенту (live address lane).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
...formatTopRows(rows, 8)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (intent === "bank_operations_by_counterparty") {
|
|
||||||
const lines = [
|
|
||||||
"Собран список банковских операций по контрагенту (live address lane).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
...formatTopRows(rows, 8)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const title = intent === "list_payables_counterparties"
|
|
||||||
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
|
||||||
: intent === "list_receivables_counterparties"
|
|
||||||
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
|
||||||
: "Срез адресного запроса собран.";
|
|
||||||
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
class AddressQueryService {
|
class AddressQueryService {
|
||||||
async tryHandle(userMessage) {
|
async tryHandle(userMessage, options = {}) {
|
||||||
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
if (!config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
|
const followupContext = options.followupContext ?? null;
|
||||||
if (mode.mode !== "address_query") {
|
const decompose = (0, decomposeStage_1.runAddressDecomposeStage)(userMessage, followupContext);
|
||||||
|
if (!decompose) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
|
const { mode, shape, intent, filters, baseReasons } = decompose;
|
||||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
|
|
||||||
const filters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
|
|
||||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
|
||||||
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters);
|
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters);
|
||||||
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
|
|
||||||
if (intent.intent === "unknown") {
|
if (intent.intent === "unknown") {
|
||||||
return buildLimitedExecutionResult({
|
return buildLimitedExecutionResult({
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -862,7 +708,7 @@ class AddressQueryService {
|
||||||
normalizedRawRows.length > 0 &&
|
normalizedRawRows.length > 0 &&
|
||||||
scopedRows.length === 0;
|
scopedRows.length === 0;
|
||||||
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
|
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
|
||||||
anchor = refineAnchorFromRows(anchor, normalizedRows);
|
anchor = (0, resolveStage_1.refineAnchorFromRows)(anchor, normalizedRows);
|
||||||
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
|
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
|
||||||
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
|
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
|
||||||
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
|
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
|
||||||
|
|
@ -895,7 +741,7 @@ class AddressQueryService {
|
||||||
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
|
: matchFailureStage === "materialized_but_filtered_out_by_recipe"
|
||||||
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
|
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
|
||||||
: null;
|
: null;
|
||||||
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
|
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && (0, composeStage_1.contractCandidatesFromRows)(filteredRows).length === 0) {
|
||||||
return buildLimitedExecutionResult({
|
return buildLimitedExecutionResult({
|
||||||
mode,
|
mode,
|
||||||
shape,
|
shape,
|
||||||
|
|
@ -925,6 +771,105 @@ class AddressQueryService {
|
||||||
reasons: baseReasons
|
reasons: baseReasons
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
|
||||||
|
const autoBroadenedFilters = { ...filters.extracted_filters };
|
||||||
|
delete autoBroadenedFilters.period_from;
|
||||||
|
delete autoBroadenedFilters.period_to;
|
||||||
|
const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters);
|
||||||
|
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||||
|
const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||||
|
const broadenedMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
|
||||||
|
query: broadenedPlan.query,
|
||||||
|
limit: broadenedPlan.limit
|
||||||
|
});
|
||||||
|
if (!broadenedMcp.error) {
|
||||||
|
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
|
||||||
|
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
|
||||||
|
const broadenedAccountScopeFallbackApplied = broadenedPlan.account_scope_mode === "preferred" &&
|
||||||
|
broadenedPlan.account_scope.length > 0 &&
|
||||||
|
broadenedRawRows.length > 0 &&
|
||||||
|
broadenedScopedRows.length === 0;
|
||||||
|
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
|
||||||
|
let broadenedAnchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, autoBroadenedFilters);
|
||||||
|
broadenedAnchor = (0, resolveStage_1.refineAnchorFromRows)(broadenedAnchor, broadenedNormalizedRows);
|
||||||
|
const broadenedFiltersForMatching = broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
|
||||||
|
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
|
||||||
|
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
|
||||||
|
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
|
||||||
|
: autoBroadenedFilters;
|
||||||
|
const broadenedAccountScopeAudit = buildAccountScopeAudit({
|
||||||
|
intent: intent.intent,
|
||||||
|
filters: broadenedFiltersForMatching,
|
||||||
|
accountScope: broadenedPlan.account_scope,
|
||||||
|
rowsBeforeScope: broadenedRawRows.length,
|
||||||
|
rowsAfterScope: broadenedNormalizedRows.length
|
||||||
|
});
|
||||||
|
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
|
||||||
|
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
|
||||||
|
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
|
||||||
|
if (broadenedFilteredRows.length > 0) {
|
||||||
|
const broadenedRowDiagnostics = deriveRowStageDiagnostics(broadenedMcp.raw_rows, broadenedNormalizedRows.length, broadenedNormalizedRows.length);
|
||||||
|
const broadenedStageStatus = deriveMcpStageStatus({
|
||||||
|
rawRowsReceived: broadenedMcp.raw_rows.length,
|
||||||
|
rowsMaterialized: broadenedNormalizedRows.length,
|
||||||
|
rowsAnchorMatched: broadenedRowsByAnchor.length,
|
||||||
|
rowsMatched: broadenedFilteredRows.length
|
||||||
|
});
|
||||||
|
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
|
||||||
|
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
|
||||||
|
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows);
|
||||||
|
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
|
||||||
|
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
|
||||||
|
reply_type: (0, composeStage_1.inferReplyType)(broadenedFactual.responseType),
|
||||||
|
response_type: broadenedFactual.responseType,
|
||||||
|
debug: {
|
||||||
|
detected_mode: mode.mode,
|
||||||
|
detected_mode_confidence: mode.confidence,
|
||||||
|
query_shape: shape.shape,
|
||||||
|
query_shape_confidence: shape.confidence,
|
||||||
|
detected_intent: intent.intent,
|
||||||
|
detected_intent_confidence: intent.confidence,
|
||||||
|
extracted_filters: filters.extracted_filters,
|
||||||
|
missing_required_filters: [],
|
||||||
|
selected_recipe: broadenedSelection.selected_recipe.recipe_id,
|
||||||
|
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
|
||||||
|
account_scope_mode: broadenedPlan.account_scope_mode,
|
||||||
|
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
|
||||||
|
anchor_type: broadenedAnchor.anchor_type,
|
||||||
|
anchor_value_raw: broadenedAnchor.anchor_value_raw,
|
||||||
|
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
|
||||||
|
resolver_confidence: broadenedAnchor.resolver_confidence,
|
||||||
|
ambiguity_count: broadenedAnchor.ambiguity_count,
|
||||||
|
match_failure_stage: "none",
|
||||||
|
match_failure_reason: null,
|
||||||
|
mcp_call_status: broadenedStageStatus,
|
||||||
|
rows_fetched: broadenedMcp.fetched_rows,
|
||||||
|
raw_rows_received: broadenedMcp.raw_rows.length,
|
||||||
|
rows_after_account_scope: broadenedNormalizedRows.length,
|
||||||
|
rows_after_recipe_filter: broadenedRowsByAnchor.length,
|
||||||
|
rows_materialized: broadenedNormalizedRows.length,
|
||||||
|
rows_matched: broadenedFilteredRows.length,
|
||||||
|
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
|
||||||
|
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
|
||||||
|
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
|
||||||
|
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
|
||||||
|
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
|
||||||
|
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
|
||||||
|
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
|
||||||
|
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
limited_reason_category: null,
|
||||||
|
response_type: broadenedFactual.responseType,
|
||||||
|
limitations: broadenedLimitations,
|
||||||
|
reasons: broadenedReasons
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (filteredRows.length === 0) {
|
if (filteredRows.length === 0) {
|
||||||
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
||||||
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||||
|
|
@ -992,11 +937,11 @@ class AddressQueryService {
|
||||||
reasons: baseReasons
|
reasons: baseReasons
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const factual = composeFactualReply(intent.intent, filteredRows);
|
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows);
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
reply_text: factual.text,
|
reply_text: factual.text,
|
||||||
reply_type: inferReplyType(factual.responseType),
|
reply_type: (0, composeStage_1.inferReplyType)(factual.responseType),
|
||||||
response_type: factual.responseType,
|
response_type: factual.responseType,
|
||||||
debug: {
|
debug: {
|
||||||
detected_mode: mode.mode,
|
detected_mode: mode.mode,
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ const BASE_RECIPES = [
|
||||||
account_scope_mode: "strict"
|
account_scope_mode: "strict"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
|
||||||
|
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
|
||||||
function toDateTimeExpr(isoDate, endOfDay) {
|
function toDateTimeExpr(isoDate, endOfDay) {
|
||||||
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|
@ -172,6 +174,12 @@ function shouldBoostLimitForAllTimeCounterparty(filters) {
|
||||||
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0));
|
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0));
|
||||||
return !hasPeriod;
|
return !hasPeriod;
|
||||||
}
|
}
|
||||||
|
function maxLimitForIntent(intent) {
|
||||||
|
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
|
||||||
|
return ADDRESS_MAX_LIMIT_EXTENDED;
|
||||||
|
}
|
||||||
|
return ADDRESS_MAX_LIMIT_DEFAULT;
|
||||||
|
}
|
||||||
function selectAddressRecipe(intent, filters) {
|
function selectAddressRecipe(intent, filters) {
|
||||||
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
|
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
|
|
@ -192,14 +200,19 @@ function selectAddressRecipe(intent, filters) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function buildAddressRecipePlan(recipe, filters) {
|
function buildAddressRecipePlan(recipe, filters) {
|
||||||
|
const maxLimit = maxLimitForIntent(recipe.intent);
|
||||||
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
|
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
|
||||||
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
|
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
|
||||||
: recipe.default_limit;
|
: recipe.default_limit;
|
||||||
const boostedLimit = (recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
|
const boostedLimit = (recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
|
||||||
shouldBoostLimitForAllTimeCounterparty(filters)
|
shouldBoostLimitForAllTimeCounterparty(filters)
|
||||||
? Math.max(baseLimit, 200)
|
? Math.max(baseLimit, maxLimit)
|
||||||
|
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
|
||||||
|
typeof filters.account === "string" &&
|
||||||
|
filters.account.trim().length > 0
|
||||||
|
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
|
||||||
: baseLimit;
|
: baseLimit;
|
||||||
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
|
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
|
||||||
const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
|
const accountScope = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
|
||||||
? [String(filters.account)]
|
? [String(filters.account)]
|
||||||
: Array.isArray(recipe.account_scope)
|
: Array.isArray(recipe.account_scope)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.contractCandidatesFromRows = contractCandidatesFromRows;
|
||||||
|
exports.composeFactualReply = composeFactualReply;
|
||||||
|
exports.inferReplyType = inferReplyType;
|
||||||
|
function uniqueStrings(values) {
|
||||||
|
return Array.from(new Set(values
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)));
|
||||||
|
}
|
||||||
|
function formatTopRows(rows, limit = 6) {
|
||||||
|
return rows.slice(0, limit).map((row, index) => {
|
||||||
|
const period = row.period ?? "дата не указана";
|
||||||
|
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
||||||
|
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
||||||
|
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
||||||
|
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function contractCandidatesFromRows(rows) {
|
||||||
|
const candidates = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const token of [row.registrator, ...row.analytics]) {
|
||||||
|
const normalized = token.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/договор|contract|дог\./i.test(normalized)) {
|
||||||
|
candidates.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqueStrings(candidates);
|
||||||
|
}
|
||||||
|
function composeFactualReply(intent, rows) {
|
||||||
|
if (intent === "account_balance_snapshot") {
|
||||||
|
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||||
|
const lines = [
|
||||||
|
"Адресный срез по счету собран (по движениям live MCP).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||||
|
...formatTopRows(rows, 4)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_SUMMARY",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "documents_forming_balance") {
|
||||||
|
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||||
|
const lines = [
|
||||||
|
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
||||||
|
`Документных строк отобрано: ${rows.length}.`,
|
||||||
|
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||||
|
...formatTopRows(rows, 8),
|
||||||
|
"Можно уточнить выборку по контрагенту, договору или периоду."
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "list_open_contracts") {
|
||||||
|
const contracts = contractCandidatesFromRows(rows);
|
||||||
|
const lines = [
|
||||||
|
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
||||||
|
`Строк движения: ${rows.length}.`,
|
||||||
|
`Договорных кандидатов: ${contracts.length}.`
|
||||||
|
];
|
||||||
|
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "open_items_by_counterparty_or_contract") {
|
||||||
|
const lines = [
|
||||||
|
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
...formatTopRows(rows, 6)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "list_documents_by_counterparty") {
|
||||||
|
const lines = [
|
||||||
|
"Собран список документов по контрагенту (live address lane).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
...formatTopRows(rows, rows.length)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "bank_operations_by_counterparty") {
|
||||||
|
const lines = [
|
||||||
|
"Собран список банковских операций по контрагенту (live address lane).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
...formatTopRows(rows, rows.length)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const title = intent === "list_payables_counterparties"
|
||||||
|
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
||||||
|
: intent === "list_receivables_counterparties"
|
||||||
|
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
||||||
|
: "Срез адресного запроса собран.";
|
||||||
|
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function inferReplyType(responseType) {
|
||||||
|
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
||||||
|
return "factual";
|
||||||
|
}
|
||||||
|
return "partial_coverage";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
|
||||||
|
exports.runAddressDecomposeStage = runAddressDecomposeStage;
|
||||||
|
const addressQueryClassifier_1 = require("../addressQueryClassifier");
|
||||||
|
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
|
||||||
|
const addressIntentResolver_1 = require("../addressIntentResolver");
|
||||||
|
const addressFilterExtractor_1 = require("../addressFilterExtractor");
|
||||||
|
function hasExplicitPeriodWindow(filters) {
|
||||||
|
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||||
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
|
||||||
|
}
|
||||||
|
function toNonEmptyString(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
function hasAllTimeHint(text) {
|
||||||
|
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(String(text ?? ""));
|
||||||
|
}
|
||||||
|
function hasAddressFollowupContextSignal(text) {
|
||||||
|
const normalized = String(text ?? "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasAllTimeHint(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalized.split(/\s+/).filter(Boolean).length <= 8;
|
||||||
|
}
|
||||||
|
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
|
const merged = { ...current };
|
||||||
|
const reasons = [];
|
||||||
|
if (!followupContext) {
|
||||||
|
return { filters: merged, reasons };
|
||||||
|
}
|
||||||
|
const previous = followupContext.previous_filters ?? {};
|
||||||
|
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
|
||||||
|
const previousCounterparty = toNonEmptyString(previous.counterparty);
|
||||||
|
const previousContract = toNonEmptyString(previous.contract);
|
||||||
|
const previousAccount = toNonEmptyString(previous.account);
|
||||||
|
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||||
|
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
|
||||||
|
if (!toNonEmptyString(merged.counterparty)) {
|
||||||
|
const inheritedCounterparty = previousCounterparty ??
|
||||||
|
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||||
|
if (inheritedCounterparty) {
|
||||||
|
merged.counterparty = inheritedCounterparty;
|
||||||
|
reasons.push("counterparty_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||||
|
if (!toNonEmptyString(merged.account)) {
|
||||||
|
const inheritedAccount = previousAccount ??
|
||||||
|
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
|
||||||
|
if (inheritedAccount) {
|
||||||
|
merged.account = inheritedAccount;
|
||||||
|
reasons.push("account_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
|
||||||
|
if (!toNonEmptyString(merged.contract)) {
|
||||||
|
const inheritedContract = previousContract ??
|
||||||
|
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||||
|
if (inheritedContract) {
|
||||||
|
merged.contract = inheritedContract;
|
||||||
|
reasons.push("contract_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!toNonEmptyString(merged.counterparty)) {
|
||||||
|
const inheritedCounterparty = previousCounterparty ??
|
||||||
|
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||||
|
if (inheritedCounterparty) {
|
||||||
|
merged.counterparty = inheritedCounterparty;
|
||||||
|
reasons.push("counterparty_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allTimeRequested) {
|
||||||
|
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
|
||||||
|
delete merged.period_from;
|
||||||
|
delete merged.period_to;
|
||||||
|
reasons.push("period_cleared_by_all_time_followup");
|
||||||
|
}
|
||||||
|
return { filters: merged, reasons };
|
||||||
|
}
|
||||||
|
const currentHasPeriod = hasExplicitPeriodWindow(merged);
|
||||||
|
const previousHasPeriod = hasExplicitPeriodWindow(previous);
|
||||||
|
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
|
||||||
|
if (toNonEmptyString(previous.period_from)) {
|
||||||
|
merged.period_from = previous.period_from;
|
||||||
|
}
|
||||||
|
if (toNonEmptyString(previous.period_to)) {
|
||||||
|
merged.period_to = previous.period_to;
|
||||||
|
}
|
||||||
|
reasons.push("period_from_followup_context");
|
||||||
|
}
|
||||||
|
return { filters: merged, reasons };
|
||||||
|
}
|
||||||
|
function resolveMissingRequiredFilters(intent, filters) {
|
||||||
|
const requiredByIntent = {
|
||||||
|
account_balance_snapshot: ["account", "as_of_date"],
|
||||||
|
documents_forming_balance: ["account", "as_of_date"],
|
||||||
|
list_documents_by_counterparty: ["counterparty"],
|
||||||
|
bank_operations_by_counterparty: ["counterparty"]
|
||||||
|
};
|
||||||
|
const required = requiredByIntent[intent] ?? [];
|
||||||
|
return required.filter((key) => {
|
||||||
|
const value = filters[key];
|
||||||
|
return value === undefined || value === null || String(value).trim() === "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
|
||||||
|
if (!followupContext || !followupContext.previous_intent) {
|
||||||
|
return detectedIntent;
|
||||||
|
}
|
||||||
|
if (detectedIntent.intent !== "unknown") {
|
||||||
|
return detectedIntent;
|
||||||
|
}
|
||||||
|
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||||
|
return detectedIntent;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
intent: followupContext.previous_intent,
|
||||||
|
confidence: "low",
|
||||||
|
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function runAddressDecomposeStage(userMessage, followupContext) {
|
||||||
|
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
|
||||||
|
const mode = detectedMode.mode === "address_query"
|
||||||
|
? detectedMode
|
||||||
|
: followupContext && hasAddressFollowupContextSignal(userMessage)
|
||||||
|
? {
|
||||||
|
mode: "address_query",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
|
||||||
|
}
|
||||||
|
: detectedMode;
|
||||||
|
if (mode.mode !== "address_query") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
|
||||||
|
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detectedIntent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
|
||||||
|
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
|
||||||
|
const extractedFilters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
|
||||||
|
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
|
||||||
|
const filters = {
|
||||||
|
extracted_filters: followupMerged.filters,
|
||||||
|
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
|
||||||
|
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
|
||||||
|
};
|
||||||
|
const followupContextApplied = Boolean(followupContext) &&
|
||||||
|
(mode.reasons.includes("address_mode_from_followup_context") ||
|
||||||
|
intent.reasons.includes("intent_from_followup_context") ||
|
||||||
|
followupMerged.reasons.length > 0);
|
||||||
|
const baseReasons = [
|
||||||
|
...mode.reasons,
|
||||||
|
...shape.reasons,
|
||||||
|
...intent.reasons,
|
||||||
|
...followupMerged.reasons,
|
||||||
|
...(followupContextApplied ? ["address_followup_context_applied"] : [])
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
shape,
|
||||||
|
intent,
|
||||||
|
filters,
|
||||||
|
baseReasons
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.resolvePrimaryAnchor = resolvePrimaryAnchor;
|
||||||
|
exports.refineAnchorFromRows = refineAnchorFromRows;
|
||||||
|
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||||
|
"ооо",
|
||||||
|
"ао",
|
||||||
|
"зао",
|
||||||
|
"ип",
|
||||||
|
"llc",
|
||||||
|
"ltd",
|
||||||
|
"company",
|
||||||
|
"компания",
|
||||||
|
"контрагент",
|
||||||
|
"counterparty",
|
||||||
|
"по",
|
||||||
|
"by"
|
||||||
|
]);
|
||||||
|
function transliterateCyrillicToLatin(value) {
|
||||||
|
const map = {
|
||||||
|
а: "a",
|
||||||
|
б: "b",
|
||||||
|
в: "v",
|
||||||
|
г: "g",
|
||||||
|
д: "d",
|
||||||
|
е: "e",
|
||||||
|
ё: "e",
|
||||||
|
ж: "zh",
|
||||||
|
з: "z",
|
||||||
|
и: "i",
|
||||||
|
й: "y",
|
||||||
|
к: "k",
|
||||||
|
л: "l",
|
||||||
|
м: "m",
|
||||||
|
н: "n",
|
||||||
|
о: "o",
|
||||||
|
п: "p",
|
||||||
|
р: "r",
|
||||||
|
с: "s",
|
||||||
|
т: "t",
|
||||||
|
у: "u",
|
||||||
|
ф: "f",
|
||||||
|
х: "h",
|
||||||
|
ц: "ts",
|
||||||
|
ч: "ch",
|
||||||
|
ш: "sh",
|
||||||
|
щ: "sch",
|
||||||
|
ъ: "",
|
||||||
|
ы: "y",
|
||||||
|
ь: "",
|
||||||
|
э: "e",
|
||||||
|
ю: "yu",
|
||||||
|
я: "ya"
|
||||||
|
};
|
||||||
|
let out = "";
|
||||||
|
for (const char of String(value ?? "").toLowerCase()) {
|
||||||
|
out += map[char] ?? char;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function normalizeSearchText(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е")
|
||||||
|
.replace(/[^a-zа-я0-9]+/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
function tokenizeAnchor(value) {
|
||||||
|
return normalizeSearchText(value)
|
||||||
|
.split(" ")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
|
||||||
|
}
|
||||||
|
function matchesAnchorText(searchable, anchor) {
|
||||||
|
const searchableNormalized = normalizeSearchText(searchable);
|
||||||
|
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||||
|
const tokens = tokenizeAnchor(anchor);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
const direct = normalizeSearchText(anchor);
|
||||||
|
if (!direct) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
|
||||||
|
}
|
||||||
|
return tokens.every((token) => {
|
||||||
|
const tokenLatin = transliterateCyrillicToLatin(token);
|
||||||
|
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function uniqueStrings(values) {
|
||||||
|
return Array.from(new Set(values
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)));
|
||||||
|
}
|
||||||
|
function resolvePrimaryAnchor(intent, filters) {
|
||||||
|
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
||||||
|
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
||||||
|
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
||||||
|
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
||||||
|
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||||
|
if (account) {
|
||||||
|
return {
|
||||||
|
anchor_type: "account",
|
||||||
|
anchor_value_raw: account,
|
||||||
|
anchor_value_resolved: account,
|
||||||
|
resolver_confidence: "high",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (counterparty) {
|
||||||
|
return {
|
||||||
|
anchor_type: "counterparty",
|
||||||
|
anchor_value_raw: counterparty,
|
||||||
|
anchor_value_resolved: counterparty,
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (contract) {
|
||||||
|
return {
|
||||||
|
anchor_type: "contract",
|
||||||
|
anchor_value_raw: contract,
|
||||||
|
anchor_value_resolved: contract,
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (documentRef) {
|
||||||
|
return {
|
||||||
|
anchor_type: "document_ref",
|
||||||
|
anchor_value_raw: documentRef,
|
||||||
|
anchor_value_resolved: documentRef,
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
anchor_type: "unknown",
|
||||||
|
anchor_value_raw: null,
|
||||||
|
anchor_value_resolved: null,
|
||||||
|
resolver_confidence: "low",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function refineAnchorFromRows(anchor, rows) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
||||||
|
if (!needleRaw) {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
const candidates = uniqueStrings(rows
|
||||||
|
.flatMap((row) => row.analytics)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw)));
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
return {
|
||||||
|
...anchor,
|
||||||
|
anchor_value_resolved: candidates[0],
|
||||||
|
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...anchor,
|
||||||
|
anchor_value_resolved: candidates[0],
|
||||||
|
resolver_confidence: "low",
|
||||||
|
ambiguity_count: candidates.length - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1731,8 +1731,9 @@ function buildAddressCoverageReport() {
|
||||||
out_of_scope_requirements: []
|
out_of_scope_requirements: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function buildAddressDebugPayload(addressDebug) {
|
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
|
||||||
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
|
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
|
||||||
|
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
|
||||||
return {
|
return {
|
||||||
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
|
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
|
||||||
prompt_version: "address_query_runtime_v1",
|
prompt_version: "address_query_runtime_v1",
|
||||||
|
|
@ -1790,12 +1791,204 @@ function buildAddressDebugPayload(addressDebug) {
|
||||||
runtime_readiness: addressDebug.runtime_readiness,
|
runtime_readiness: addressDebug.runtime_readiness,
|
||||||
limited_reason_category: addressDebug.limited_reason_category,
|
limited_reason_category: addressDebug.limited_reason_category,
|
||||||
response_type: addressDebug.response_type,
|
response_type: addressDebug.response_type,
|
||||||
|
execution_lane: "address_query",
|
||||||
|
llm_decomposition_applied: Boolean(llmMeta?.applied),
|
||||||
|
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
|
||||||
|
llm_provider_used: llmMeta?.provider ?? null,
|
||||||
|
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
|
||||||
|
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
|
||||||
|
llm_decomposition_reason: llmMeta?.reason ?? null,
|
||||||
answer_structure_v11: null,
|
answer_structure_v11: null,
|
||||||
investigation_state_snapshot: null,
|
investigation_state_snapshot: null,
|
||||||
normalized: null,
|
normalized: null,
|
||||||
normalizer_output: null
|
normalizer_output: llmMeta?.traceId
|
||||||
|
? {
|
||||||
|
trace_id: llmMeta.traceId,
|
||||||
|
prompt_version: "normalizer_v2_0_2",
|
||||||
|
applied: Boolean(llmMeta?.applied),
|
||||||
|
effective_message: llmMeta?.effectiveMessage ?? null
|
||||||
|
}
|
||||||
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function toNonEmptyString(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
function readAddressFilterString(addressDebug, key) {
|
||||||
|
const filters = addressDebug?.extracted_filters;
|
||||||
|
if (!filters || typeof filters !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toNonEmptyString(filters[key]);
|
||||||
|
}
|
||||||
|
function findLastAddressAssistantDebug(items) {
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item || item.role !== "assistant" || !item.debug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const debug = item.debug;
|
||||||
|
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
|
||||||
|
return debug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hasAddressFollowupContextSignal(userMessage) {
|
||||||
|
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hasReferentialPointer(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const shortFollowup = countTokens(text) <= 8;
|
||||||
|
if (shortFollowup && hasFollowupMarker(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function resolveAddressFollowupCarryoverContext(userMessage, items) {
|
||||||
|
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previousAddressDebug = findLastAddressAssistantDebug(items);
|
||||||
|
if (!previousAddressDebug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
|
||||||
|
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||||
|
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||||
|
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
||||||
|
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
||||||
|
readAddressFilterString(previousAddressDebug, "account") ??
|
||||||
|
readAddressFilterString(previousAddressDebug, "contract");
|
||||||
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
|
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||||
|
? { ...previousFiltersRaw }
|
||||||
|
: {};
|
||||||
|
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
followupContext: {
|
||||||
|
previous_intent: previousIntent ?? undefined,
|
||||||
|
previous_filters: previousFilters,
|
||||||
|
previous_anchor_type: previousAnchorType ?? undefined,
|
||||||
|
previous_anchor_value: previousAnchor
|
||||||
|
},
|
||||||
|
previousAddressIntent: previousIntent,
|
||||||
|
previousAddressAnchor: previousAnchor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function isAddressLlmPreDecomposeCandidate(userMessage) {
|
||||||
|
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
|
||||||
|
}
|
||||||
|
function extractAddressQuestionFromNormalized(normalized) {
|
||||||
|
if (!normalized || typeof normalized !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const source = normalized;
|
||||||
|
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
|
||||||
|
for (const item of fragments) {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fragment = item;
|
||||||
|
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
|
||||||
|
if (domainRelevance === "out_of_scope") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
|
||||||
|
if (readiness === "no_route") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
|
||||||
|
const rawText = toNonEmptyString(fragment.raw_fragment_text);
|
||||||
|
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
|
||||||
|
if (candidate.length >= 3 && candidate.length <= 500) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
|
||||||
|
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
|
||||||
|
const baseMeta = {
|
||||||
|
attempted: false,
|
||||||
|
applied: false,
|
||||||
|
provider,
|
||||||
|
traceId: null,
|
||||||
|
effectiveMessage: userMessage,
|
||||||
|
reason: "not_attempted"
|
||||||
|
};
|
||||||
|
if (Boolean(payload?.useMock)) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
reason: "skipped_in_mock"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
reason: "not_address_like"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalizePayload = {
|
||||||
|
llmProvider: payload?.llmProvider,
|
||||||
|
apiKey: payload?.apiKey,
|
||||||
|
model: payload?.model,
|
||||||
|
baseUrl: payload?.baseUrl,
|
||||||
|
temperature: 0,
|
||||||
|
maxOutputTokens: payload?.maxOutputTokens,
|
||||||
|
promptVersion: "normalizer_v2_0_2",
|
||||||
|
userQuestion: userMessage,
|
||||||
|
context: payload?.context,
|
||||||
|
useMock: Boolean(payload?.useMock),
|
||||||
|
retryPolicy: "single-pass-strict"
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const normalized = await normalizerService.normalize(normalizePayload);
|
||||||
|
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
|
||||||
|
if (!normalized?.ok || !candidate) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
attempted: true,
|
||||||
|
traceId: normalized?.trace_id ?? null,
|
||||||
|
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
const candidateCompact = compactWhitespace(candidate.toLowerCase());
|
||||||
|
const applied = sourceCompact !== candidateCompact;
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
applied,
|
||||||
|
provider,
|
||||||
|
traceId: normalized?.trace_id ?? null,
|
||||||
|
effectiveMessage: applied ? candidate : userMessage,
|
||||||
|
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
attempted: true,
|
||||||
|
reason: `error:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
class AssistantService {
|
class AssistantService {
|
||||||
normalizerService;
|
normalizerService;
|
||||||
sessions;
|
sessions;
|
||||||
|
|
@ -1827,10 +2020,8 @@ class AssistantService {
|
||||||
debug: null
|
debug: null
|
||||||
};
|
};
|
||||||
this.sessions.appendItem(sessionId, userItem);
|
this.sessions.appendItem(sessionId, userItem);
|
||||||
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
|
||||||
const addressLane = await this.addressQueryService.tryHandle(userMessage);
|
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
|
||||||
if (addressLane?.handled) {
|
|
||||||
const debug = buildAddressDebugPayload(addressLane.debug);
|
|
||||||
const assistantItem = {
|
const assistantItem = {
|
||||||
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
|
|
@ -1858,6 +2049,15 @@ class AssistantService {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message_id: assistantItem.message_id,
|
message_id: assistantItem.message_id,
|
||||||
user_message: userMessage,
|
user_message: userMessage,
|
||||||
|
effective_address_user_message: effectiveAddressUserMessage,
|
||||||
|
address_followup_context_applied: Boolean(carryoverMeta),
|
||||||
|
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
|
||||||
|
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
|
||||||
|
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
|
||||||
|
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
|
||||||
|
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
|
||||||
|
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
|
||||||
|
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
|
||||||
detected_mode: addressLane.debug.detected_mode,
|
detected_mode: addressLane.debug.detected_mode,
|
||||||
query_shape: addressLane.debug.query_shape,
|
query_shape: addressLane.debug.query_shape,
|
||||||
detected_intent: addressLane.debug.detected_intent,
|
detected_intent: addressLane.debug.detected_intent,
|
||||||
|
|
@ -1901,6 +2101,31 @@ class AssistantService {
|
||||||
debug,
|
debug,
|
||||||
conversation
|
conversation
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||||
|
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
|
||||||
|
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
|
||||||
|
: {
|
||||||
|
attempted: false,
|
||||||
|
applied: false,
|
||||||
|
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
|
||||||
|
traceId: null,
|
||||||
|
effectiveMessage: userMessage,
|
||||||
|
reason: "disabled_by_feature_flag"
|
||||||
|
};
|
||||||
|
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
|
||||||
|
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
|
||||||
|
if (primaryAddressLane?.handled) {
|
||||||
|
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
|
||||||
|
}
|
||||||
|
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
|
||||||
|
if (carryover?.followupContext) {
|
||||||
|
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
|
||||||
|
followupContext: carryover.followupContext
|
||||||
|
});
|
||||||
|
if (contextualAddressLane?.handled) {
|
||||||
|
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
|
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
|
||||||
|
|
@ -1917,12 +2142,13 @@ class AssistantService {
|
||||||
usage: null
|
usage: null
|
||||||
};
|
};
|
||||||
const normalizePayload = {
|
const normalizePayload = {
|
||||||
|
llmProvider: payload.llmProvider,
|
||||||
apiKey: payload.apiKey,
|
apiKey: payload.apiKey,
|
||||||
model: payload.model,
|
model: payload.model,
|
||||||
baseUrl: payload.baseUrl,
|
baseUrl: payload.baseUrl,
|
||||||
temperature: payload.temperature,
|
temperature: payload.temperature,
|
||||||
maxOutputTokens: payload.maxOutputTokens,
|
maxOutputTokens: payload.maxOutputTokens,
|
||||||
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
|
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
|
||||||
systemPrompt: payload.systemPrompt,
|
systemPrompt: payload.systemPrompt,
|
||||||
developerPrompt: payload.developerPrompt,
|
developerPrompt: payload.developerPrompt,
|
||||||
domainPrompt: payload.domainPrompt,
|
domainPrompt: payload.domainPrompt,
|
||||||
|
|
|
||||||
|
|
@ -871,6 +871,7 @@ class NormalizerService {
|
||||||
async normalize(payload) {
|
async normalize(payload) {
|
||||||
const traceId = (0, nanoid_1.nanoid)(14);
|
const traceId = (0, nanoid_1.nanoid)(14);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
|
||||||
const model = payload.model ?? config_1.DEFAULT_MODEL;
|
const model = payload.model ?? config_1.DEFAULT_MODEL;
|
||||||
const baseUrl = payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL;
|
const baseUrl = payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL;
|
||||||
const temperature = payload.temperature ?? config_1.DEFAULT_TEMPERATURE;
|
const temperature = payload.temperature ?? config_1.DEFAULT_TEMPERATURE;
|
||||||
|
|
@ -903,6 +904,7 @@ class NormalizerService {
|
||||||
else {
|
else {
|
||||||
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
|
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
|
||||||
const firstTry = await this.openaiClient.normalize({
|
const firstTry = await this.openaiClient.normalize({
|
||||||
|
llmProvider,
|
||||||
apiKey: String(apiKey ?? ""),
|
apiKey: String(apiKey ?? ""),
|
||||||
model,
|
model,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
@ -946,6 +948,7 @@ class NormalizerService {
|
||||||
if (!payload.useMock && !validation.passed && canRetry) {
|
if (!payload.useMock && !validation.passed && canRetry) {
|
||||||
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
|
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
|
||||||
const retry = await this.openaiClient.normalize({
|
const retry = await this.openaiClient.normalize({
|
||||||
|
llmProvider,
|
||||||
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||||
model,
|
model,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,20 @@ const fs_1 = __importDefault(require("fs"));
|
||||||
const path_1 = __importDefault(require("path"));
|
const path_1 = __importDefault(require("path"));
|
||||||
const config_1 = require("../config");
|
const config_1 = require("../config");
|
||||||
const http_1 = require("../utils/http");
|
const http_1 = require("../utils/http");
|
||||||
|
function resolveProvider(config) {
|
||||||
|
return config.llmProvider === "local" ? "local" : "openai";
|
||||||
|
}
|
||||||
|
function resolveApiKey(config) {
|
||||||
|
const candidate = String(config.apiKey ?? "").trim();
|
||||||
|
if (candidate.length > 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (resolveProvider(config) === "local") {
|
||||||
|
// Local OpenAI-compatible servers often accept any token.
|
||||||
|
return "local-dev-token";
|
||||||
|
}
|
||||||
|
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
|
||||||
|
}
|
||||||
function extractUsage(raw) {
|
function extractUsage(raw) {
|
||||||
const usage = (raw.usage ?? {});
|
const usage = (raw.usage ?? {});
|
||||||
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
|
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
|
||||||
|
|
@ -19,7 +33,7 @@ function extractUsage(raw) {
|
||||||
total_tokens: Number.isFinite(total) ? total : 0
|
total_tokens: Number.isFinite(total) ? total : 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function extractOutputText(raw) {
|
function extractOutputTextFromResponses(raw) {
|
||||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||||
return raw.output_text;
|
return raw.output_text;
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +65,55 @@ function extractOutputText(raw) {
|
||||||
return nested.output_text;
|
return nested.output_text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
|
||||||
|
}
|
||||||
|
function extractOutputTextFromChatCompletions(raw) {
|
||||||
|
const choices = raw.choices;
|
||||||
|
if (!Array.isArray(choices) || choices.length === 0) {
|
||||||
|
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
const first = choices[0];
|
||||||
|
if (!first || typeof first !== "object") {
|
||||||
|
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
const message = first.message;
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
const content = message.content;
|
||||||
|
if (typeof content === "string" && content.trim().length > 0) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const textParts = content
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const block = item;
|
||||||
|
return typeof block.text === "string" ? block.text : "";
|
||||||
|
})
|
||||||
|
.filter((item) => item.trim().length > 0);
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
return textParts.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
function shouldFallbackToChatCompletions(error) {
|
||||||
|
if (!(error instanceof http_1.ApiError)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (error.code !== "OPENAI_REQUEST_FAILED") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const details = (error.details ?? {});
|
||||||
|
const status = Number(details.status ?? 0);
|
||||||
|
if ([404, 405, 501].includes(status)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const message = String(error.message ?? "").toLowerCase();
|
||||||
|
return message.includes("/responses") || message.includes("responses");
|
||||||
}
|
}
|
||||||
function loadSchemaForTransport(schemaVersion) {
|
function loadSchemaForTransport(schemaVersion) {
|
||||||
const schemaFile = schemaVersion === "v1"
|
const schemaFile = schemaVersion === "v1"
|
||||||
|
|
@ -64,19 +126,54 @@ function loadSchemaForTransport(schemaVersion) {
|
||||||
const schemaPath = path_1.default.resolve(config_1.SCHEMAS_DIR, schemaFile);
|
const schemaPath = path_1.default.resolve(config_1.SCHEMAS_DIR, schemaFile);
|
||||||
return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8"));
|
return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8"));
|
||||||
}
|
}
|
||||||
class OpenAIResponsesClient {
|
function buildBaseUrlCandidates(config) {
|
||||||
async testConnection(config) {
|
const base = (config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
||||||
const payload = {
|
const provider = resolveProvider(config);
|
||||||
model: config.model,
|
if (provider !== "local") {
|
||||||
input: [
|
return [base];
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "input_text", text: "ping" }]
|
|
||||||
}
|
}
|
||||||
],
|
const hasVersionSuffix = /\/v\d+$/i.test(base);
|
||||||
|
if (hasVersionSuffix) {
|
||||||
|
return [base];
|
||||||
|
}
|
||||||
|
return Array.from(new Set([base, `${base}/v1`]));
|
||||||
|
}
|
||||||
|
class OpenAIResponsesClient {
|
||||||
|
async listModels(config) {
|
||||||
|
const payload = await this.getModels(config);
|
||||||
|
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||||
|
const ids = data
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String(item.id ?? "").trim();
|
||||||
|
})
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
return Array.from(new Set(ids));
|
||||||
|
}
|
||||||
|
async testConnection(config) {
|
||||||
|
const provider = resolveProvider(config);
|
||||||
|
if (provider === "local") {
|
||||||
|
try {
|
||||||
|
await this.getModels(config);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
|
||||||
|
await this.postChatCompletions(config, {
|
||||||
|
model: config.model,
|
||||||
|
messages: [{ role: "user", content: "ping" }],
|
||||||
|
max_tokens: 4,
|
||||||
|
temperature: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, model: config.model };
|
||||||
|
}
|
||||||
|
await this.postResponses(config, {
|
||||||
|
model: config.model,
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }],
|
||||||
max_output_tokens: 16
|
max_output_tokens: 16
|
||||||
};
|
});
|
||||||
await this.post(config, payload);
|
|
||||||
return { ok: true, model: config.model };
|
return { ok: true, model: config.model };
|
||||||
}
|
}
|
||||||
async normalize(config, prompt) {
|
async normalize(config, prompt) {
|
||||||
|
|
@ -91,7 +188,7 @@ class OpenAIResponsesClient {
|
||||||
const developerPrompt = prompt.controlledRetryInstruction
|
const developerPrompt = prompt.controlledRetryInstruction
|
||||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||||
: prompt.developerPrompt;
|
: prompt.developerPrompt;
|
||||||
const payload = {
|
const responsesPayload = {
|
||||||
model: config.model,
|
model: config.model,
|
||||||
temperature: config.temperature ?? 0,
|
temperature: config.temperature ?? 0,
|
||||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||||
|
|
@ -109,7 +206,7 @@ class OpenAIResponsesClient {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "input_text",
|
type: "input_text",
|
||||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -123,38 +220,122 @@ class OpenAIResponsesClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const raw = await this.post(config, payload);
|
const provider = resolveProvider(config);
|
||||||
const outputText = extractOutputText(raw);
|
if (provider === "openai") {
|
||||||
|
const raw = await this.postResponses(config, responsesPayload);
|
||||||
return {
|
return {
|
||||||
raw,
|
raw,
|
||||||
outputText,
|
outputText: extractOutputTextFromResponses(raw),
|
||||||
usage: extractUsage(raw)
|
usage: extractUsage(raw)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async post(config, payload) {
|
// local provider: prefer /responses if available, fallback to /chat/completions
|
||||||
if (!config.apiKey || config.apiKey.trim().length < 10) {
|
try {
|
||||||
throw new http_1.ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
|
const raw = await this.postResponses(config, responsesPayload);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
outputText: extractOutputTextFromResponses(raw),
|
||||||
|
usage: extractUsage(raw)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const url = `${(config.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
|
catch (error) {
|
||||||
const response = await fetch(url, {
|
if (!shouldFallbackToChatCompletions(error)) {
|
||||||
method: "POST",
|
throw error;
|
||||||
headers: {
|
}
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
}
|
||||||
"Content-Type": "application/json"
|
const chatPayload = {
|
||||||
|
model: config.model,
|
||||||
|
temperature: config.temperature ?? 0,
|
||||||
|
max_tokens: config.maxOutputTokens ?? 700,
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
|
||||||
|
`Return only JSON that matches schema: ${schemaName}.`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const raw = await this.postChatCompletions(config, chatPayload);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
outputText: extractOutputTextFromChatCompletions(raw),
|
||||||
|
usage: extractUsage(raw)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async getModels(config) {
|
||||||
|
return this.requestJson(config, "/models", "GET");
|
||||||
|
}
|
||||||
|
async postResponses(config, payload) {
|
||||||
|
return this.requestJson(config, "/responses", "POST", payload);
|
||||||
|
}
|
||||||
|
async postChatCompletions(config, payload) {
|
||||||
|
return this.requestJson(config, "/chat/completions", "POST", payload);
|
||||||
|
}
|
||||||
|
async requestJson(config, routePath, method, payload) {
|
||||||
|
const apiKey = resolveApiKey(config);
|
||||||
|
const baseCandidates = buildBaseUrlCandidates(config);
|
||||||
|
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
|
||||||
|
let lastNetworkError = null;
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
};
|
||||||
|
if (method === "POST") {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
for (let index = 0; index < baseCandidates.length; index += 1) {
|
||||||
|
const base = baseCandidates[index];
|
||||||
|
const isLastCandidate = index === baseCandidates.length - 1;
|
||||||
|
const url = `${base}${routePath}`;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
lastNetworkError = error;
|
||||||
|
if (!isLastCandidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||||
|
route: routePath,
|
||||||
|
url,
|
||||||
|
reason: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let data;
|
let data = {};
|
||||||
|
if (text.trim().length > 0) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text);
|
data = JSON.parse(text);
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
|
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new http_1.ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
|
||||||
|
route: routePath,
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
body: text.slice(0, 500)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorObj = (data.error ?? {});
|
const errorObj = (data.error ?? {});
|
||||||
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `OpenAI request failed with status ${response.status}`), response.status, {
|
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", String(errorObj.message ?? `Model endpoint failed: ${response.status}`), response.status, {
|
||||||
|
route: routePath,
|
||||||
|
url,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
type: errorObj.type ?? null,
|
type: errorObj.type ?? null,
|
||||||
code: errorObj.code ?? null
|
code: errorObj.code ?? null
|
||||||
|
|
@ -162,5 +343,10 @@ class OpenAIResponsesClient {
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||||
|
route: routePath,
|
||||||
|
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exports.OpenAIResponsesClient = OpenAIResponsesClient;
|
exports.OpenAIResponsesClient = OpenAIResponsesClient;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
"llm-normalizer-workspace": "file:..",
|
"llm-normalizer-workspace": "file:..",
|
||||||
"nanoid": "^5.1.5"
|
"nanoid": "^5.1.5"
|
||||||
},
|
},
|
||||||
|
|
@ -1224,6 +1225,18 @@
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
|
@ -1923,15 +1936,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
|
|
@ -2280,6 +2297,18 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
"llm-normalizer-workspace": "file:..",
|
"llm-normalizer-workspace": "file:..",
|
||||||
"nanoid": "^5.1.5"
|
"nanoid": "^5.1.5"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(
|
||||||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
|
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(
|
||||||
|
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1,
|
||||||
|
true
|
||||||
|
);
|
||||||
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
|
export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
|
||||||
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Router } from "express";
|
import { NextFunction, Request, Response, Router } from "express";
|
||||||
import { DEFAULT_MODEL, DEFAULT_OPENAI_BASE_URL } from "../config";
|
import { DEFAULT_MODEL, DEFAULT_OPENAI_BASE_URL } from "../config";
|
||||||
import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
|
import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
|
||||||
import { ok } from "../utils/http";
|
import { ok } from "../utils/http";
|
||||||
|
|
@ -6,23 +6,76 @@ import { ok } from "../utils/http";
|
||||||
export function buildTestConnectionRouter(client: OpenAIResponsesClient): Router {
|
export function buildTestConnectionRouter(client: OpenAIResponsesClient): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post("/api/openai/test-connection", async (req, res, next) => {
|
const handler = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const llmProvider = body.llmProvider === "local" ? "local" : "openai";
|
||||||
|
const model = String(body.model ?? DEFAULT_MODEL);
|
||||||
|
const baseUrl = String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL);
|
||||||
|
const apiKey = String(body.apiKey ?? process.env.OPENAI_API_KEY ?? "");
|
||||||
const result = await client.testConnection({
|
const result = await client.testConnection({
|
||||||
|
llmProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
baseUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
let modelFound: boolean | null = null;
|
||||||
|
let modelsCount: number | null = null;
|
||||||
|
if (llmProvider === "local") {
|
||||||
|
try {
|
||||||
|
const models = await client.listModels({
|
||||||
|
llmProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
baseUrl
|
||||||
|
});
|
||||||
|
modelsCount = models.length;
|
||||||
|
modelFound = models.includes(model);
|
||||||
|
} catch {
|
||||||
|
modelFound = null;
|
||||||
|
modelsCount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
ok: true,
|
||||||
|
provider: llmProvider,
|
||||||
|
model: result.model,
|
||||||
|
model_found: modelFound,
|
||||||
|
models_count: modelsCount,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listModelsHandler = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const models = await client.listModels({
|
||||||
|
llmProvider: body.llmProvider === "local" ? "local" : "openai",
|
||||||
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
apiKey: String(body.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||||
model: String(body.model ?? DEFAULT_MODEL),
|
model: String(body.model ?? DEFAULT_MODEL),
|
||||||
baseUrl: String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL)
|
baseUrl: String(body.baseUrl ?? DEFAULT_OPENAI_BASE_URL)
|
||||||
});
|
});
|
||||||
ok(res, {
|
ok(res, {
|
||||||
ok: true,
|
ok: true,
|
||||||
model: result.model,
|
models,
|
||||||
|
count: models.length,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
router.post("/api/llm/test-connection", handler);
|
||||||
|
router.post("/api/llm/models", listModelsHandler);
|
||||||
|
// Backward-compatible route for old frontend builds.
|
||||||
|
router.post("/api/openai/test-connection", handler);
|
||||||
|
router.post("/api/openai/models", listModelsHandler);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,24 @@
|
||||||
|
|
||||||
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
||||||
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
|
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
|
||||||
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|by\s+counterparty|counterparty)\s+([^\r\n,.;:]+)/i;
|
const COUNTERPARTY_PATTERN =
|
||||||
|
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
|
||||||
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
|
||||||
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
|
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
|
||||||
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
|
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
|
||||||
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||||
const PERIOD_RANGE_PATTERN_2 =
|
const PERIOD_RANGE_PATTERN_2 =
|
||||||
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
|
||||||
|
const YEAR_RANGE_PATTERN =
|
||||||
|
/(?:за|for|с|from)?\s*(20\d{2})\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?=[^\d]|$)/iu;
|
||||||
|
const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|до|to|по)\s*\b(20\d{2})\b/iu;
|
||||||
|
const YEAR_PERIOD_PATTERN =
|
||||||
|
/(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year)?/iu;
|
||||||
|
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year)(?=$|[\s,.;:!?()\-])/iu;
|
||||||
|
const YEAR_PERIOD_ANY_PATTERN =
|
||||||
|
/(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
|
||||||
|
const MONTH_PERIOD_NUMERIC_PATTERN = /(?:за|for)\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})/i;
|
||||||
|
const MONTH_PERIOD_NAME_PATTERN = /(?:за|for)\s+([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?/iu;
|
||||||
|
|
||||||
function toIsoDate(year: number, month: number, day: number): string | null {
|
function toIsoDate(year: number, month: number, day: number): string | null {
|
||||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||||
|
|
@ -68,6 +79,57 @@ function parseDateToken(token: string): string | undefined {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMonthByName(rawMonthName: string): number | undefined {
|
||||||
|
const token = String(rawMonthName ?? "").trim().toLowerCase();
|
||||||
|
if (!token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^янв|^january|^jan/.test(token)) return 1;
|
||||||
|
if (/^фев|^february|^feb/.test(token)) return 2;
|
||||||
|
if (/^мар|^march|^mar/.test(token)) return 3;
|
||||||
|
if (/^апр|^april|^apr/.test(token)) return 4;
|
||||||
|
if (/^ма[йя]|^may/.test(token)) return 5;
|
||||||
|
if (/^июн|^june|^jun/.test(token)) return 6;
|
||||||
|
if (/^июл|^july|^jul/.test(token)) return 7;
|
||||||
|
if (/^авг|^august|^aug/.test(token)) return 8;
|
||||||
|
if (/^сен|^сент|^september|^sep/.test(token)) return 9;
|
||||||
|
if (/^окт|^october|^oct/.test(token)) return 10;
|
||||||
|
if (/^ноя|^november|^nov/.test(token)) return 11;
|
||||||
|
if (/^дек|^december|^dec/.test(token)) return 12;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
|
||||||
|
const numericMatch = text.match(MONTH_PERIOD_NUMERIC_PATTERN);
|
||||||
|
if (numericMatch) {
|
||||||
|
const month = Number(numericMatch[1]);
|
||||||
|
const year = Number(numericMatch[2]);
|
||||||
|
if (month >= 1 && month <= 12 && year >= 2000 && year <= 2099) {
|
||||||
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
return {
|
||||||
|
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
|
||||||
|
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byNameMatch = text.match(MONTH_PERIOD_NAME_PATTERN);
|
||||||
|
if (byNameMatch) {
|
||||||
|
const month = resolveMonthByName(String(byNameMatch[1]));
|
||||||
|
const year = Number(byNameMatch[2]);
|
||||||
|
if (month && year >= 2000 && year <= 2099) {
|
||||||
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
return {
|
||||||
|
period_from: `${year}-${String(month).padStart(2, "0")}-01`,
|
||||||
|
period_to: `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
|
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
|
||||||
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
|
||||||
if (!directMatch) {
|
if (!directMatch) {
|
||||||
|
|
@ -81,6 +143,70 @@ function extractPeriodRange(text: string): { period_from?: string; period_to?: s
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractYearPeriod(text: string): { period_from?: string; period_to?: string } {
|
||||||
|
const match = text.match(YEAR_PERIOD_PATTERN);
|
||||||
|
if (match) {
|
||||||
|
const year = Number(match[1]);
|
||||||
|
if (!Number.isFinite(year) || year < 2000 || year > 2099) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
period_from: `${year}-01-01`,
|
||||||
|
period_to: `${year}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const relaxedYearMatch = text.match(YEAR_PERIOD_ANY_PATTERN);
|
||||||
|
if (relaxedYearMatch) {
|
||||||
|
const year = Number(relaxedYearMatch[1]);
|
||||||
|
if (Number.isFinite(year) && year >= 2000 && year <= 2099) {
|
||||||
|
return {
|
||||||
|
period_from: `${year}-01-01`,
|
||||||
|
period_to: `${year}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortYearMatch = text.match(YEAR_PERIOD_SHORT_PATTERN);
|
||||||
|
if (!shortYearMatch) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const shortYear = Number(shortYearMatch[1]);
|
||||||
|
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const year = 2000 + shortYear;
|
||||||
|
return {
|
||||||
|
period_from: `${year}-01-01`,
|
||||||
|
period_to: `${year}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYearRangePeriod(text: string): { period_from?: string; period_to?: string } {
|
||||||
|
const match = text.match(YEAR_RANGE_PATTERN) ?? text.match(YEAR_RANGE_LOOSE_PATTERN);
|
||||||
|
if (!match) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const leftYear = Number(match[1]);
|
||||||
|
const rightYear = Number(match[2]);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(leftYear) ||
|
||||||
|
!Number.isFinite(rightYear) ||
|
||||||
|
leftYear < 2000 ||
|
||||||
|
leftYear > 2099 ||
|
||||||
|
rightYear < 2000 ||
|
||||||
|
rightYear > 2099
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const fromYear = Math.min(leftYear, rightYear);
|
||||||
|
const toYear = Math.max(leftYear, rightYear);
|
||||||
|
return {
|
||||||
|
period_from: `${fromYear}-01-01`,
|
||||||
|
period_to: `${toYear}-12-31`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupAnchorValue(value: string): string {
|
function cleanupAnchorValue(value: string): string {
|
||||||
const normalized = String(value ?? "").trim();
|
const normalized = String(value ?? "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|
@ -95,11 +221,13 @@ function cleanupAnchorValue(value: string): string {
|
||||||
return normalized.replace(periodTailPattern, "").trim();
|
return normalized.replace(periodTailPattern, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTimeTailPattern = /\s+за\s+вс[её]\s+время(?:\s+|$)[\s\S]*$/iu;
|
const allTimeTailPattern =
|
||||||
|
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
|
||||||
if (allTimeTailPattern.test(normalized)) {
|
if (allTimeTailPattern.test(normalized)) {
|
||||||
return normalized.replace(allTimeTailPattern, "").trim();
|
return normalized.replace(allTimeTailPattern, "").trim();
|
||||||
}
|
}
|
||||||
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time)(?:\s+|$)[\s\S]*$/iu;
|
const allTimeTailPatternEn =
|
||||||
|
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
|
||||||
if (allTimeTailPatternEn.test(normalized)) {
|
if (allTimeTailPatternEn.test(normalized)) {
|
||||||
return normalized.replace(allTimeTailPatternEn, "").trim();
|
return normalized.replace(allTimeTailPatternEn, "").trim();
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +240,203 @@ function cleanupAnchorValue(value: string): string {
|
||||||
|
|
||||||
function hasAllTimeHint(text: string): boolean {
|
function hasAllTimeHint(text: string): boolean {
|
||||||
const value = String(text ?? "");
|
const value = String(text ?? "");
|
||||||
return /(?:за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(value);
|
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLooseByAnchorValue(text: string): string | undefined {
|
||||||
|
const match = String(text ?? "").match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const token = String(match[1] ?? "").trim();
|
||||||
|
if (!token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
const stopWords = new Set([
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"контре",
|
||||||
|
"компании",
|
||||||
|
"компанию",
|
||||||
|
"организации",
|
||||||
|
"организацию",
|
||||||
|
"поставщику",
|
||||||
|
"поставщика",
|
||||||
|
"клиенту",
|
||||||
|
"клиента",
|
||||||
|
"покупателю",
|
||||||
|
"покупателя",
|
||||||
|
"партнеру",
|
||||||
|
"партнера",
|
||||||
|
"договору",
|
||||||
|
"договора",
|
||||||
|
"счету",
|
||||||
|
"счёту",
|
||||||
|
"дате",
|
||||||
|
"периоду",
|
||||||
|
"период",
|
||||||
|
"документам",
|
||||||
|
"докам",
|
||||||
|
"взаиморасчетам",
|
||||||
|
"взаиморасчётам"
|
||||||
|
]);
|
||||||
|
if (stopWords.has(lowered)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||||
|
const token = String(rawToken ?? "").trim();
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (!token || token.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(lowered)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(?:19|20)\d{2}$/.test(lowered)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopWords = new Set([
|
||||||
|
"за",
|
||||||
|
"с",
|
||||||
|
"по",
|
||||||
|
"на",
|
||||||
|
"и",
|
||||||
|
"или",
|
||||||
|
"док",
|
||||||
|
"доки",
|
||||||
|
"документ",
|
||||||
|
"документы",
|
||||||
|
"документов",
|
||||||
|
"банк",
|
||||||
|
"банковские",
|
||||||
|
"операции",
|
||||||
|
"платежи",
|
||||||
|
"платеж",
|
||||||
|
"платёж",
|
||||||
|
"контрагент",
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"компания",
|
||||||
|
"компании",
|
||||||
|
"организация",
|
||||||
|
"организации",
|
||||||
|
"год",
|
||||||
|
"года",
|
||||||
|
"г",
|
||||||
|
"плс",
|
||||||
|
"pls",
|
||||||
|
"пж",
|
||||||
|
"пжлст",
|
||||||
|
"пожалуйста",
|
||||||
|
"бля",
|
||||||
|
"блять",
|
||||||
|
"епт",
|
||||||
|
"ёпт",
|
||||||
|
"епта",
|
||||||
|
"нах",
|
||||||
|
"нахуй",
|
||||||
|
"покеж",
|
||||||
|
"покажи",
|
||||||
|
"выведи"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(lowered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDocsOrBankSignal(text: string): boolean {
|
||||||
|
const lowered = String(text ?? "").toLowerCase();
|
||||||
|
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||||
|
lowered
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined {
|
||||||
|
if (!hasDocsOrBankSignal(text)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = String(text ?? "")
|
||||||
|
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthTokens = [
|
||||||
|
"янв",
|
||||||
|
"фев",
|
||||||
|
"мар",
|
||||||
|
"апр",
|
||||||
|
"май",
|
||||||
|
"июн",
|
||||||
|
"июл",
|
||||||
|
"авг",
|
||||||
|
"сен",
|
||||||
|
"сент",
|
||||||
|
"окт",
|
||||||
|
"ноя",
|
||||||
|
"дек",
|
||||||
|
"january",
|
||||||
|
"february",
|
||||||
|
"march",
|
||||||
|
"april",
|
||||||
|
"may",
|
||||||
|
"june",
|
||||||
|
"july",
|
||||||
|
"august",
|
||||||
|
"september",
|
||||||
|
"october",
|
||||||
|
"november",
|
||||||
|
"december"
|
||||||
|
];
|
||||||
|
for (const token of tokens) {
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (!isLikelyCounterpartyToken(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImplicitCounterpartyValue(text: string): string | undefined {
|
||||||
|
const input = String(text ?? "");
|
||||||
|
const beforeDocsMatch = input.match(
|
||||||
|
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
|
||||||
|
);
|
||||||
|
if (beforeDocsMatch) {
|
||||||
|
const candidate = String(beforeDocsMatch[1] ?? "").trim();
|
||||||
|
if (isLikelyCounterpartyToken(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterDocsMatch = input.match(
|
||||||
|
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||||
|
);
|
||||||
|
if (afterDocsMatch) {
|
||||||
|
const candidate = String(afterDocsMatch[1] ?? "").trim();
|
||||||
|
if (isLikelyCounterpartyToken(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftDaysIso(baseIso: string, deltaDays: number): string {
|
function shiftDaysIso(baseIso: string, deltaDays: number): string {
|
||||||
|
|
@ -156,6 +480,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
if (counterpartyMatch) {
|
if (counterpartyMatch) {
|
||||||
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
||||||
}
|
}
|
||||||
|
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||||
|
const fallbackCounterparty = extractLooseByAnchorValue(text);
|
||||||
|
if (fallbackCounterparty) {
|
||||||
|
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
|
||||||
|
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||||
|
const implicitCounterparty = extractImplicitCounterpartyValue(text);
|
||||||
|
if (implicitCounterparty) {
|
||||||
|
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
|
||||||
|
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
|
||||||
|
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
|
||||||
|
if (heuristicCounterparty) {
|
||||||
|
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
|
||||||
|
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contractMatch = text.match(CONTRACT_PATTERN);
|
const contractMatch = text.match(CONTRACT_PATTERN);
|
||||||
if (contractMatch) {
|
if (contractMatch) {
|
||||||
|
|
@ -170,6 +515,33 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
filters.period_to = periodRange.period_to;
|
filters.period_to = periodRange.period_to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!filters.period_from && !filters.period_to) {
|
||||||
|
const monthPeriod = extractMonthPeriod(text);
|
||||||
|
if (monthPeriod.period_from && monthPeriod.period_to) {
|
||||||
|
filters.period_from = monthPeriod.period_from;
|
||||||
|
filters.period_to = monthPeriod.period_to;
|
||||||
|
warnings.push("period_derived_from_month_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filters.period_from && !filters.period_to) {
|
||||||
|
const yearRangePeriod = extractYearRangePeriod(text);
|
||||||
|
if (yearRangePeriod.period_from && yearRangePeriod.period_to) {
|
||||||
|
filters.period_from = yearRangePeriod.period_from;
|
||||||
|
filters.period_to = yearRangePeriod.period_to;
|
||||||
|
warnings.push("period_derived_from_year_range_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filters.period_from && !filters.period_to) {
|
||||||
|
const yearPeriod = extractYearPeriod(text);
|
||||||
|
if (yearPeriod.period_from && yearPeriod.period_to) {
|
||||||
|
filters.period_from = yearPeriod.period_from;
|
||||||
|
filters.period_to = yearPeriod.period_to;
|
||||||
|
warnings.push("period_derived_from_year_phrase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
|
// If explicit period window exists, do not infer as_of_date from one of its boundary dates.
|
||||||
if (!filters.period_from && !filters.period_to) {
|
if (!filters.period_from && !filters.period_to) {
|
||||||
const asOfDate = extractAsOfDate(text);
|
const asOfDate = extractAsOfDate(text);
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,20 @@ const OPEN_ITEMS_HINTS = [
|
||||||
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
|
const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
|
||||||
"documents by counterparty",
|
"documents by counterparty",
|
||||||
"docs by counterparty",
|
"docs by counterparty",
|
||||||
|
"documents by company",
|
||||||
|
"documents by supplier",
|
||||||
|
"documents by customer",
|
||||||
|
"documents by client",
|
||||||
|
"documents by partner",
|
||||||
"show documents by counterparty",
|
"show documents by counterparty",
|
||||||
"list documents by counterparty",
|
"list documents by counterparty",
|
||||||
|
"документы по",
|
||||||
|
"доступные документы",
|
||||||
|
"список документов",
|
||||||
"документ",
|
"документ",
|
||||||
|
"доки",
|
||||||
|
"доки по",
|
||||||
|
"док по",
|
||||||
"по контрагент"
|
"по контрагент"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -77,16 +88,202 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
|
||||||
"bank operations by counterparty",
|
"bank operations by counterparty",
|
||||||
"bank payments by counterparty",
|
"bank payments by counterparty",
|
||||||
"payment orders by counterparty",
|
"payment orders by counterparty",
|
||||||
|
"bank operations by company",
|
||||||
|
"bank operations by supplier",
|
||||||
|
"bank operations by customer",
|
||||||
"show bank operations by counterparty",
|
"show bank operations by counterparty",
|
||||||
|
"bank ops",
|
||||||
|
"transactions by counterparty",
|
||||||
"банков",
|
"банков",
|
||||||
"выписк",
|
"выписк",
|
||||||
"платеж"
|
"платеж",
|
||||||
|
"платёж",
|
||||||
|
"оплат",
|
||||||
|
"списан",
|
||||||
|
"поступлен",
|
||||||
|
"движени"
|
||||||
];
|
];
|
||||||
|
|
||||||
function hasAny(text: string, patterns: string[]): boolean {
|
function hasAny(text: string, patterns: string[]): boolean {
|
||||||
return patterns.some((item) => text.includes(item));
|
return patterns.some((item) => text.includes(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||||
|
const token = String(rawToken ?? "").trim().toLowerCase();
|
||||||
|
if (!token || token.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(?:19|20)\d{2}$/.test(token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"за",
|
||||||
|
"с",
|
||||||
|
"по",
|
||||||
|
"на",
|
||||||
|
"и",
|
||||||
|
"или",
|
||||||
|
"док",
|
||||||
|
"доки",
|
||||||
|
"доки?",
|
||||||
|
"документ",
|
||||||
|
"документы",
|
||||||
|
"документов",
|
||||||
|
"банк",
|
||||||
|
"банковские",
|
||||||
|
"операции",
|
||||||
|
"платежи",
|
||||||
|
"платеж",
|
||||||
|
"платёж",
|
||||||
|
"контрагент",
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"компания",
|
||||||
|
"компании",
|
||||||
|
"организация",
|
||||||
|
"организации",
|
||||||
|
"год",
|
||||||
|
"года",
|
||||||
|
"г",
|
||||||
|
"плс",
|
||||||
|
"pls",
|
||||||
|
"пж",
|
||||||
|
"пжлст",
|
||||||
|
"пожалуйста",
|
||||||
|
"бля",
|
||||||
|
"блять",
|
||||||
|
"епт",
|
||||||
|
"ёпт",
|
||||||
|
"епта",
|
||||||
|
"нах",
|
||||||
|
"нахуй"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPartyAnchorMention(text: string): boolean {
|
||||||
|
return (
|
||||||
|
text.includes("контраг") ||
|
||||||
|
text.includes("контра") ||
|
||||||
|
text.includes("counterparty") ||
|
||||||
|
text.includes("компан") ||
|
||||||
|
text.includes("company") ||
|
||||||
|
text.includes("организац") ||
|
||||||
|
text.includes("supplier") ||
|
||||||
|
text.includes("vendor") ||
|
||||||
|
text.includes("customer") ||
|
||||||
|
text.includes("client") ||
|
||||||
|
text.includes("partner") ||
|
||||||
|
text.includes("поставщик") ||
|
||||||
|
text.includes("клиент") ||
|
||||||
|
text.includes("покупател") ||
|
||||||
|
text.includes("партнер")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLooseByAnchorMention(text: string): boolean {
|
||||||
|
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const token = String(match[1] ?? "").toLowerCase();
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"контре",
|
||||||
|
"компании",
|
||||||
|
"компанию",
|
||||||
|
"организации",
|
||||||
|
"организацию",
|
||||||
|
"поставщику",
|
||||||
|
"поставщика",
|
||||||
|
"клиенту",
|
||||||
|
"клиента",
|
||||||
|
"покупателю",
|
||||||
|
"покупателя",
|
||||||
|
"партнеру",
|
||||||
|
"партнера",
|
||||||
|
"договору",
|
||||||
|
"договора",
|
||||||
|
"счету",
|
||||||
|
"счёту",
|
||||||
|
"дате",
|
||||||
|
"периоду",
|
||||||
|
"период",
|
||||||
|
"документам",
|
||||||
|
"докам"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean {
|
||||||
|
const beforeDocsMatch = text.match(
|
||||||
|
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
|
||||||
|
);
|
||||||
|
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterDocsMatch = text.match(
|
||||||
|
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||||
|
);
|
||||||
|
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDocsOrBankSignal(text: string): boolean {
|
||||||
|
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHeuristicCounterpartyAnchor(text: string): boolean {
|
||||||
|
if (!hasDocsOrBankSignal(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tokens = String(text ?? "")
|
||||||
|
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
for (const token of tokens) {
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (!isLikelyCounterpartyToken(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGenericAddressLookupSignal(text: string): boolean {
|
||||||
|
return (
|
||||||
|
/\bесть\b/iu.test(text) ||
|
||||||
|
/\bпокажи\b/iu.test(text) ||
|
||||||
|
/\bвыведи\b/iu.test(text) ||
|
||||||
|
/\bкакие\b/iu.test(text) ||
|
||||||
|
/\bчто(?:-|\s)?то\b/iu.test(text) ||
|
||||||
|
/за\s+любой\s+период/iu.test(text) ||
|
||||||
|
/за\s+вс[её]\s+время/iu.test(text) ||
|
||||||
|
/for\s+all\s+time/iu.test(text) ||
|
||||||
|
/all\s+time/iu.test(text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasAccountNumberAnchor(text: string): boolean {
|
function hasAccountNumberAnchor(text: string): boolean {
|
||||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +325,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
|
hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) &&
|
||||||
(text.includes("контраг") || text.includes("counterparty"))
|
(hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || hasHeuristicCounterpartyAnchor(text))
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
intent: "bank_operations_by_counterparty",
|
intent: "bank_operations_by_counterparty",
|
||||||
|
|
@ -139,7 +336,10 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
hasAny(text, DOCUMENTS_BY_COUNTERPARTY_HINTS) &&
|
||||||
(text.includes("контраг") || text.includes("counterparty"))
|
(hasPartyAnchorMention(text) ||
|
||||||
|
hasLooseByAnchorMention(text) ||
|
||||||
|
hasImplicitCounterpartyAnchorAroundDocs(text) ||
|
||||||
|
hasHeuristicCounterpartyAnchor(text))
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
intent: "list_documents_by_counterparty",
|
intent: "list_documents_by_counterparty",
|
||||||
|
|
@ -148,6 +348,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasLooseByAnchorMention(text) && hasGenericAddressLookupSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "list_documents_by_counterparty",
|
||||||
|
confidence: "low",
|
||||||
|
reasons: ["generic_lookup_with_loose_anchor_fallback"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
|
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
|
||||||
return {
|
return {
|
||||||
intent: "open_items_by_counterparty_or_contract",
|
intent: "open_items_by_counterparty_or_contract",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
ASSISTANT_MCP_PROXY_URL,
|
ASSISTANT_MCP_PROXY_URL,
|
||||||
ASSISTANT_MCP_TIMEOUT_MS
|
ASSISTANT_MCP_TIMEOUT_MS
|
||||||
} from "../config";
|
} from "../config";
|
||||||
|
import iconv from "iconv-lite";
|
||||||
|
|
||||||
interface McpExecuteQueryResponse {
|
interface McpExecuteQueryResponse {
|
||||||
success?: unknown;
|
success?: unknown;
|
||||||
|
|
@ -36,8 +37,81 @@ function parseFiniteNumber(value: unknown): number | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textMojibakeScore(value: string): number {
|
||||||
|
const source = String(value ?? "");
|
||||||
|
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
|
||||||
|
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
|
||||||
|
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length;
|
||||||
|
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
|
||||||
|
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeMojibake(value: string): boolean {
|
||||||
|
const source = String(value ?? "");
|
||||||
|
if (!source.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUtf8FromWin1251Mojibake(value: string): string {
|
||||||
|
if (!looksLikeMojibake(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bytes = iconv.encode(value, "win1251");
|
||||||
|
const decoded = bytes.toString("utf8");
|
||||||
|
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUtf8FromLatin1Mojibake(value: string): string {
|
||||||
|
if (!looksLikeMojibake(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(value, "latin1").toString("utf8");
|
||||||
|
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMojibakeString(value: string): string {
|
||||||
|
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
|
||||||
|
return decodeUtf8FromLatin1Mojibake(fromWin1251);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMojibakeValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return normalizeMojibakeString(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeMojibakeValue(item));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
for (const [key, raw] of Object.entries(source)) {
|
||||||
|
const repairedKey = normalizeMojibakeString(key);
|
||||||
|
normalized[repairedKey] = normalizeMojibakeValue(raw);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMojibakeRows(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||||
|
return rows.map((row) => normalizeMojibakeValue(row) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
function parseRowsFromTextTable(source: string): Array<Record<string, unknown>> {
|
function parseRowsFromTextTable(source: string): Array<Record<string, unknown>> {
|
||||||
const normalized = String(source ?? "").replace(/\r/g, "").trim();
|
const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +185,7 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return normalizeMojibakeRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
||||||
|
|
@ -133,9 +207,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(source.data)) {
|
if (Array.isArray(source.data)) {
|
||||||
const rows = source.data
|
const rows = normalizeMojibakeRows(
|
||||||
|
source.data
|
||||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||||
.filter((item): item is Record<string, unknown> => item !== null);
|
.filter((item): item is Record<string, unknown> => item !== null)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -152,9 +228,11 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) {
|
if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) {
|
||||||
const rows = ((source.data as { rows: unknown[] }).rows ?? [])
|
const rows = normalizeMojibakeRows(
|
||||||
|
((source.data as { rows: unknown[] }).rows ?? [])
|
||||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||||
.filter((item): item is Record<string, unknown> => item !== null);
|
.filter((item): item is Record<string, unknown> => item !== null)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rows,
|
rows,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ const ADDRESS_ACTION_TOKENS = [
|
||||||
const ADDRESS_ENTITY_TOKENS = [
|
const ADDRESS_ENTITY_TOKENS = [
|
||||||
"counterparty",
|
"counterparty",
|
||||||
"counterparties",
|
"counterparties",
|
||||||
|
"company",
|
||||||
|
"organization",
|
||||||
|
"supplier",
|
||||||
|
"vendor",
|
||||||
|
"customer",
|
||||||
|
"client",
|
||||||
|
"partner",
|
||||||
"contract",
|
"contract",
|
||||||
"contracts",
|
"contracts",
|
||||||
"account",
|
"account",
|
||||||
|
|
@ -42,10 +49,22 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||||
"owes",
|
"owes",
|
||||||
"owed",
|
"owed",
|
||||||
"контрагент",
|
"контрагент",
|
||||||
|
"контра",
|
||||||
|
"компан",
|
||||||
|
"организац",
|
||||||
|
"поставщик",
|
||||||
|
"клиент",
|
||||||
|
"покупател",
|
||||||
|
"партнер",
|
||||||
|
"банк",
|
||||||
|
"выписк",
|
||||||
|
"операц",
|
||||||
"договор",
|
"договор",
|
||||||
"счет",
|
"счет",
|
||||||
"счёт",
|
"счёт",
|
||||||
"документ",
|
"документ",
|
||||||
|
"доки",
|
||||||
|
"док",
|
||||||
"остаток",
|
"остаток",
|
||||||
"дебитор",
|
"дебитор",
|
||||||
"кредитор",
|
"кредитор",
|
||||||
|
|
@ -73,6 +92,56 @@ const DEEP_REASONING_TOKENS = [
|
||||||
"ошибк"
|
"ошибк"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function hasLooseByAnchorMention(text: string): boolean {
|
||||||
|
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const token = String(match[1] ?? "").toLowerCase();
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stopWords = new Set([
|
||||||
|
"контрагенту",
|
||||||
|
"контрагента",
|
||||||
|
"контре",
|
||||||
|
"компании",
|
||||||
|
"компанию",
|
||||||
|
"организации",
|
||||||
|
"организацию",
|
||||||
|
"поставщику",
|
||||||
|
"поставщика",
|
||||||
|
"клиенту",
|
||||||
|
"клиента",
|
||||||
|
"покупателю",
|
||||||
|
"покупателя",
|
||||||
|
"партнеру",
|
||||||
|
"партнера",
|
||||||
|
"договору",
|
||||||
|
"договора",
|
||||||
|
"счету",
|
||||||
|
"счёту",
|
||||||
|
"дате",
|
||||||
|
"периоду",
|
||||||
|
"период",
|
||||||
|
"документам",
|
||||||
|
"докам",
|
||||||
|
"взаиморасчетам",
|
||||||
|
"взаиморасчётам"
|
||||||
|
]);
|
||||||
|
return !stopWords.has(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAddressFollowupSignal(text: string): boolean {
|
||||||
|
if (/(?:за\s+любой\s+период|за\s+вс[её]\s+время|for\s+all\s+time|all\s+time)/iu.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/(?:\bесть\s+что(?:-|\s)?то\b|\bесть\s+ли\b|\bчто\s+есть\b)/iu.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function hasAnyToken(text: string, tokens: string[]): boolean {
|
function hasAnyToken(text: string, tokens: string[]): boolean {
|
||||||
return tokens.some((token) => text.includes(token));
|
return tokens.some((token) => text.includes(token));
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +159,8 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||||
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
|
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
|
||||||
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
|
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
|
||||||
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
|
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
|
||||||
|
const hasLooseByAnchor = hasLooseByAnchorMention(text);
|
||||||
|
const hasFollowupSignal = hasAddressFollowupSignal(text);
|
||||||
|
|
||||||
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
|
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -99,6 +170,14 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
|
||||||
|
return {
|
||||||
|
mode: "address_query",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAddressEntity && !hasDeepReasoning) {
|
if (hasAddressEntity && !hasDeepReasoning) {
|
||||||
return {
|
return {
|
||||||
mode: "address_query",
|
mode: "address_query",
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,11 @@ import type {
|
||||||
AddressResponseType,
|
AddressResponseType,
|
||||||
AddressRuntimeReadiness
|
AddressRuntimeReadiness
|
||||||
} from "../types/addressQuery";
|
} from "../types/addressQuery";
|
||||||
import { detectAddressQuestionMode } from "./addressQueryClassifier";
|
|
||||||
import { classifyAddressQueryShape } from "./addressQueryShapeClassifier";
|
|
||||||
import { resolveAddressIntent } from "./addressIntentResolver";
|
|
||||||
import { extractAddressFilters } from "./addressFilterExtractor";
|
|
||||||
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
|
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
|
||||||
import { executeAddressMcpQuery } from "./addressMcpClient";
|
import { executeAddressMcpQuery } from "./addressMcpClient";
|
||||||
|
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
|
||||||
|
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
|
||||||
|
import { composeFactualReply, contractCandidatesFromRows, inferReplyType } from "./address_runtime/composeStage";
|
||||||
|
|
||||||
interface NormalizedAddressRow {
|
interface NormalizedAddressRow {
|
||||||
period: string | null;
|
period: string | null;
|
||||||
|
|
@ -29,6 +28,10 @@ interface NormalizedAddressRow {
|
||||||
analytics: string[];
|
analytics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddressTryHandleOptions {
|
||||||
|
followupContext?: AddressFollowupContext | null;
|
||||||
|
}
|
||||||
|
|
||||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
|
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
|
||||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
|
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
|
||||||
const PARTY_ANCHOR_STOPWORDS = new Set([
|
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||||
|
|
@ -388,21 +391,65 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTopRows(rows: NormalizedAddressRow[], limit = 6): string[] {
|
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
|
||||||
return rows.slice(0, limit).map((row, index) => {
|
return (
|
||||||
const period = row.period ?? "дата не указана";
|
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||||
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
|
||||||
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
);
|
||||||
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
|
||||||
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
|
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
|
||||||
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
if (!hasExplicitPeriodWindow(filters)) {
|
||||||
return "factual";
|
return false;
|
||||||
}
|
}
|
||||||
return "partial_coverage";
|
return intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDatePrefix(value: string | null): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveObservedPeriodWindow(rows: NormalizedAddressRow[]): { period_from: string | null; period_to: string | null } {
|
||||||
|
const dates = rows
|
||||||
|
.map((row) => toIsoDatePrefix(row.period))
|
||||||
|
.filter((item): item is string => Boolean(item))
|
||||||
|
.sort();
|
||||||
|
if (dates.length === 0) {
|
||||||
|
return {
|
||||||
|
period_from: null,
|
||||||
|
period_to: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
period_from: dates[0],
|
||||||
|
period_to: dates[dates.length - 1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeAutoBroadenedPeriodPrefix(
|
||||||
|
requested: AddressFilterSet,
|
||||||
|
observed: { period_from: string | null; period_to: string | null }
|
||||||
|
): string {
|
||||||
|
const requestedFrom = typeof requested.period_from === "string" ? requested.period_from : null;
|
||||||
|
const requestedTo = typeof requested.period_to === "string" ? requested.period_to : null;
|
||||||
|
if (requestedFrom && requestedTo && observed.period_from && observed.period_to) {
|
||||||
|
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные ${observed.period_from}..${observed.period_to}.`;
|
||||||
|
}
|
||||||
|
if (requestedFrom && requestedTo) {
|
||||||
|
return `По окну ${requestedFrom}..${requestedTo} строк не найдено; показаны ближайшие доступные данные по этому якорю.`;
|
||||||
|
}
|
||||||
|
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
|
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
|
||||||
|
|
@ -418,14 +465,6 @@ function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCatego
|
||||||
return "UNKNOWN";
|
return "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnchorResolutionDebug {
|
|
||||||
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
|
||||||
anchor_value_raw: string | null;
|
|
||||||
anchor_value_resolved: string | null;
|
|
||||||
resolver_confidence: "high" | "medium" | "low" | null;
|
|
||||||
ambiguity_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RowStageDiagnostics {
|
interface RowStageDiagnostics {
|
||||||
rawRowKeysSample: string[];
|
rawRowKeysSample: string[];
|
||||||
materializationDropReason:
|
materializationDropReason:
|
||||||
|
|
@ -580,99 +619,6 @@ function toLegacyMcpStatus(
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
|
|
||||||
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
|
||||||
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
|
||||||
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
|
||||||
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
|
||||||
|
|
||||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
|
||||||
if (account) {
|
|
||||||
return {
|
|
||||||
anchor_type: "account",
|
|
||||||
anchor_value_raw: account,
|
|
||||||
anchor_value_resolved: account,
|
|
||||||
resolver_confidence: "high",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (counterparty) {
|
|
||||||
return {
|
|
||||||
anchor_type: "counterparty",
|
|
||||||
anchor_value_raw: counterparty,
|
|
||||||
anchor_value_resolved: counterparty,
|
|
||||||
resolver_confidence: "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contract) {
|
|
||||||
return {
|
|
||||||
anchor_type: "contract",
|
|
||||||
anchor_value_raw: contract,
|
|
||||||
anchor_value_resolved: contract,
|
|
||||||
resolver_confidence: "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (documentRef) {
|
|
||||||
return {
|
|
||||||
anchor_type: "document_ref",
|
|
||||||
anchor_value_raw: documentRef,
|
|
||||||
anchor_value_resolved: documentRef,
|
|
||||||
resolver_confidence: "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
anchor_type: "unknown",
|
|
||||||
anchor_value_raw: null,
|
|
||||||
anchor_value_resolved: null,
|
|
||||||
resolver_confidence: "low",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: NormalizedAddressRow[]): AnchorResolutionDebug {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
|
||||||
if (!needleRaw) {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
const candidates = uniqueStrings(
|
|
||||||
rows
|
|
||||||
.flatMap((row) => row.analytics)
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
|
|
||||||
);
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return anchor;
|
|
||||||
}
|
|
||||||
if (candidates.length === 1) {
|
|
||||||
return {
|
|
||||||
...anchor,
|
|
||||||
anchor_value_resolved: candidates[0],
|
|
||||||
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
|
||||||
ambiguity_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...anchor,
|
|
||||||
anchor_value_resolved: candidates[0],
|
|
||||||
resolver_confidence: "low",
|
|
||||||
ambiguity_count: candidates.length - 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
|
function composeLimitedReply(category: AddressLimitedReasonCategory, reason: string, nextStep?: string): string {
|
||||||
const heading =
|
const heading =
|
||||||
category === "empty_match"
|
category === "empty_match"
|
||||||
|
|
@ -777,137 +723,20 @@ function buildLimitedExecutionResult(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function contractCandidatesFromRows(rows: NormalizedAddressRow[]): string[] {
|
|
||||||
const candidates: string[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
for (const token of [row.registrator, ...row.analytics]) {
|
|
||||||
const normalized = token.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/договор|contract|дог\./i.test(normalized)) {
|
|
||||||
candidates.push(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uniqueStrings(candidates);
|
|
||||||
}
|
|
||||||
|
|
||||||
function composeFactualReply(intent: AddressIntent, rows: NormalizedAddressRow[]): { responseType: AddressResponseType; text: string } {
|
|
||||||
if (intent === "account_balance_snapshot") {
|
|
||||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
|
||||||
const lines = [
|
|
||||||
"Адресный срез по счету собран (по движениям live MCP).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
|
||||||
...formatTopRows(rows, 4)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_SUMMARY",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "documents_forming_balance") {
|
|
||||||
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
|
||||||
const lines = [
|
|
||||||
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
|
||||||
`Документных строк отобрано: ${rows.length}.`,
|
|
||||||
`Сумма по отобранным движениям: ${movementSum}.`,
|
|
||||||
...formatTopRows(rows, 8),
|
|
||||||
"Можно уточнить выборку по контрагенту, договору или периоду."
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "list_open_contracts") {
|
|
||||||
const contracts = contractCandidatesFromRows(rows);
|
|
||||||
const lines = [
|
|
||||||
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
|
||||||
`Строк движения: ${rows.length}.`,
|
|
||||||
`Договорных кандидатов: ${contracts.length}.`
|
|
||||||
];
|
|
||||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "open_items_by_counterparty_or_contract") {
|
|
||||||
const lines = [
|
|
||||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
...formatTopRows(rows, 6)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "list_documents_by_counterparty") {
|
|
||||||
const lines = [
|
|
||||||
"Собран список документов по контрагенту (live address lane).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
...formatTopRows(rows, 8)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "bank_operations_by_counterparty") {
|
|
||||||
const lines = [
|
|
||||||
"Собран список банковских операций по контрагенту (live address lane).",
|
|
||||||
`Строк отобрано: ${rows.length}.`,
|
|
||||||
...formatTopRows(rows, 8)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const title =
|
|
||||||
intent === "list_payables_counterparties"
|
|
||||||
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
|
||||||
: intent === "list_receivables_counterparties"
|
|
||||||
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
|
||||||
: "Срез адресного запроса собран.";
|
|
||||||
|
|
||||||
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
|
||||||
return {
|
|
||||||
responseType: "FACTUAL_LIST",
|
|
||||||
text: lines.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AddressQueryService {
|
export class AddressQueryService {
|
||||||
public async tryHandle(userMessage: string): Promise<AddressExecutionResult | null> {
|
public async tryHandle(userMessage: string, options: AddressTryHandleOptions = {}): Promise<AddressExecutionResult | null> {
|
||||||
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
if (!FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = detectAddressQuestionMode(userMessage);
|
const followupContext = options.followupContext ?? null;
|
||||||
if (mode.mode !== "address_query") {
|
const decompose = runAddressDecomposeStage(userMessage, followupContext);
|
||||||
|
if (!decompose) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const { mode, shape, intent, filters, baseReasons } = decompose;
|
||||||
const shape = classifyAddressQueryShape(userMessage);
|
|
||||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intent = resolveAddressIntent(userMessage);
|
|
||||||
const filters = extractAddressFilters(userMessage, intent.intent);
|
|
||||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||||
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
|
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
|
||||||
const baseReasons = [...mode.reasons, ...shape.reasons, ...intent.reasons];
|
|
||||||
|
|
||||||
if (intent.intent === "unknown") {
|
if (intent.intent === "unknown") {
|
||||||
return buildLimitedExecutionResult({
|
return buildLimitedExecutionResult({
|
||||||
|
|
@ -1130,6 +959,112 @@ export class AddressQueryService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filteredRows.length === 0 && canAutoBroadenPeriodWindow(intent.intent, filters.extracted_filters)) {
|
||||||
|
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
|
||||||
|
delete autoBroadenedFilters.period_from;
|
||||||
|
delete autoBroadenedFilters.period_to;
|
||||||
|
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
|
||||||
|
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||||
|
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||||
|
const broadenedMcp = await executeAddressMcpQuery({
|
||||||
|
query: broadenedPlan.query,
|
||||||
|
limit: broadenedPlan.limit
|
||||||
|
});
|
||||||
|
if (!broadenedMcp.error) {
|
||||||
|
const broadenedRawRows = toNormalizedRows(broadenedMcp.raw_rows);
|
||||||
|
const broadenedScopedRows = applyAccountScopeFilter(broadenedRawRows, broadenedPlan.account_scope);
|
||||||
|
const broadenedAccountScopeFallbackApplied =
|
||||||
|
broadenedPlan.account_scope_mode === "preferred" &&
|
||||||
|
broadenedPlan.account_scope.length > 0 &&
|
||||||
|
broadenedRawRows.length > 0 &&
|
||||||
|
broadenedScopedRows.length === 0;
|
||||||
|
const broadenedNormalizedRows = broadenedAccountScopeFallbackApplied ? broadenedRawRows : broadenedScopedRows;
|
||||||
|
let broadenedAnchor = resolvePrimaryAnchor(intent.intent, autoBroadenedFilters);
|
||||||
|
broadenedAnchor = refineAnchorFromRows(broadenedAnchor, broadenedNormalizedRows);
|
||||||
|
const broadenedFiltersForMatching: AddressFilterSet =
|
||||||
|
broadenedAnchor.anchor_type === "counterparty" && broadenedAnchor.anchor_value_resolved
|
||||||
|
? { ...autoBroadenedFilters, counterparty: broadenedAnchor.anchor_value_resolved }
|
||||||
|
: broadenedAnchor.anchor_type === "contract" && broadenedAnchor.anchor_value_resolved
|
||||||
|
? { ...autoBroadenedFilters, contract: broadenedAnchor.anchor_value_resolved }
|
||||||
|
: autoBroadenedFilters;
|
||||||
|
const broadenedAccountScopeAudit = buildAccountScopeAudit({
|
||||||
|
intent: intent.intent,
|
||||||
|
filters: broadenedFiltersForMatching,
|
||||||
|
accountScope: broadenedPlan.account_scope,
|
||||||
|
rowsBeforeScope: broadenedRawRows.length,
|
||||||
|
rowsAfterScope: broadenedNormalizedRows.length
|
||||||
|
});
|
||||||
|
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
|
||||||
|
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
|
||||||
|
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
|
||||||
|
if (broadenedFilteredRows.length > 0) {
|
||||||
|
const broadenedRowDiagnostics = deriveRowStageDiagnostics(
|
||||||
|
broadenedMcp.raw_rows,
|
||||||
|
broadenedNormalizedRows.length,
|
||||||
|
broadenedNormalizedRows.length
|
||||||
|
);
|
||||||
|
const broadenedStageStatus = deriveMcpStageStatus({
|
||||||
|
rawRowsReceived: broadenedMcp.raw_rows.length,
|
||||||
|
rowsMaterialized: broadenedNormalizedRows.length,
|
||||||
|
rowsAnchorMatched: broadenedRowsByAnchor.length,
|
||||||
|
rowsMatched: broadenedFilteredRows.length
|
||||||
|
});
|
||||||
|
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
|
||||||
|
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
|
||||||
|
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
|
||||||
|
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
|
||||||
|
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
|
||||||
|
reply_type: inferReplyType(broadenedFactual.responseType),
|
||||||
|
response_type: broadenedFactual.responseType,
|
||||||
|
debug: {
|
||||||
|
detected_mode: mode.mode,
|
||||||
|
detected_mode_confidence: mode.confidence,
|
||||||
|
query_shape: shape.shape,
|
||||||
|
query_shape_confidence: shape.confidence,
|
||||||
|
detected_intent: intent.intent,
|
||||||
|
detected_intent_confidence: intent.confidence,
|
||||||
|
extracted_filters: filters.extracted_filters,
|
||||||
|
missing_required_filters: [],
|
||||||
|
selected_recipe: broadenedSelection.selected_recipe.recipe_id,
|
||||||
|
mcp_call_status_legacy: toLegacyMcpStatus(broadenedStageStatus),
|
||||||
|
account_scope_mode: broadenedPlan.account_scope_mode,
|
||||||
|
account_scope_fallback_applied: broadenedAccountScopeFallbackApplied,
|
||||||
|
anchor_type: broadenedAnchor.anchor_type,
|
||||||
|
anchor_value_raw: broadenedAnchor.anchor_value_raw,
|
||||||
|
anchor_value_resolved: broadenedAnchor.anchor_value_resolved,
|
||||||
|
resolver_confidence: broadenedAnchor.resolver_confidence,
|
||||||
|
ambiguity_count: broadenedAnchor.ambiguity_count,
|
||||||
|
match_failure_stage: "none",
|
||||||
|
match_failure_reason: null,
|
||||||
|
mcp_call_status: broadenedStageStatus,
|
||||||
|
rows_fetched: broadenedMcp.fetched_rows,
|
||||||
|
raw_rows_received: broadenedMcp.raw_rows.length,
|
||||||
|
rows_after_account_scope: broadenedNormalizedRows.length,
|
||||||
|
rows_after_recipe_filter: broadenedRowsByAnchor.length,
|
||||||
|
rows_materialized: broadenedNormalizedRows.length,
|
||||||
|
rows_matched: broadenedFilteredRows.length,
|
||||||
|
raw_row_keys_sample: broadenedRowDiagnostics.rawRowKeysSample,
|
||||||
|
materialization_drop_reason: broadenedRowDiagnostics.materializationDropReason,
|
||||||
|
account_token_raw: broadenedAccountScopeAudit.accountTokenRaw,
|
||||||
|
account_token_normalized: broadenedAccountScopeAudit.accountTokenNormalized,
|
||||||
|
account_scope_fields_checked: broadenedAccountScopeAudit.accountScopeFieldsChecked,
|
||||||
|
account_scope_match_strategy: broadenedAccountScopeAudit.accountScopeMatchStrategy,
|
||||||
|
account_scope_drop_reason: broadenedAccountScopeAudit.accountScopeDropReason,
|
||||||
|
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
limited_reason_category: null,
|
||||||
|
response_type: broadenedFactual.responseType,
|
||||||
|
limitations: broadenedLimitations,
|
||||||
|
reasons: broadenedReasons
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (filteredRows.length === 0) {
|
if (filteredRows.length === 0) {
|
||||||
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
|
||||||
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
const hadAnchorMatchedRows = filterByAnchors.length > 0;
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,9 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ADDRESS_MAX_LIMIT_DEFAULT = 200;
|
||||||
|
const ADDRESS_MAX_LIMIT_EXTENDED = 1000;
|
||||||
|
|
||||||
export interface AddressRecipeExecutionPlan {
|
export interface AddressRecipeExecutionPlan {
|
||||||
recipe: AddressRecipeDefinition;
|
recipe: AddressRecipeDefinition;
|
||||||
query: string;
|
query: string;
|
||||||
|
|
@ -196,6 +199,13 @@ function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): bool
|
||||||
return !hasPeriod;
|
return !hasPeriod;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maxLimitForIntent(intent: AddressIntent): number {
|
||||||
|
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
|
||||||
|
return ADDRESS_MAX_LIMIT_EXTENDED;
|
||||||
|
}
|
||||||
|
return ADDRESS_MAX_LIMIT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
|
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
|
||||||
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
|
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
|
|
@ -222,16 +232,21 @@ export function buildAddressRecipePlan(
|
||||||
recipe: AddressRecipeDefinition,
|
recipe: AddressRecipeDefinition,
|
||||||
filters: AddressFilterSet
|
filters: AddressFilterSet
|
||||||
): AddressRecipeExecutionPlan {
|
): AddressRecipeExecutionPlan {
|
||||||
|
const maxLimit = maxLimitForIntent(recipe.intent);
|
||||||
const baseLimit =
|
const baseLimit =
|
||||||
typeof filters.limit === "number" && Number.isFinite(filters.limit)
|
typeof filters.limit === "number" && Number.isFinite(filters.limit)
|
||||||
? Math.max(1, Math.min(200, Math.trunc(filters.limit)))
|
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
|
||||||
: recipe.default_limit;
|
: recipe.default_limit;
|
||||||
const boostedLimit =
|
const boostedLimit =
|
||||||
(recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
|
(recipe.intent === "list_documents_by_counterparty" || recipe.intent === "bank_operations_by_counterparty") &&
|
||||||
shouldBoostLimitForAllTimeCounterparty(filters)
|
shouldBoostLimitForAllTimeCounterparty(filters)
|
||||||
? Math.max(baseLimit, 200)
|
? Math.max(baseLimit, maxLimit)
|
||||||
|
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
|
||||||
|
typeof filters.account === "string" &&
|
||||||
|
filters.account.trim().length > 0
|
||||||
|
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
|
||||||
: baseLimit;
|
: baseLimit;
|
||||||
const resolvedLimit = Math.max(1, Math.min(200, boostedLimit));
|
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
|
||||||
|
|
||||||
const accountScope =
|
const accountScope =
|
||||||
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
|
(recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import type { AddressIntent, AddressResponseType } from "../../types/addressQuery";
|
||||||
|
|
||||||
|
export interface ComposeStageRow {
|
||||||
|
period: string | null;
|
||||||
|
registrator: string;
|
||||||
|
account_dt: string | null;
|
||||||
|
account_kt: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
analytics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
values
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
|
||||||
|
return rows.slice(0, limit).map((row, index) => {
|
||||||
|
const period = row.period ?? "дата не указана";
|
||||||
|
const amount = row.amount !== null ? `${row.amount}` : "сумма не указана";
|
||||||
|
const accounts = [row.account_dt ?? "-", row.account_kt ?? "-"].join(" / ");
|
||||||
|
const analytics = row.analytics.length > 0 ? ` | аналитика: ${row.analytics.slice(0, 2).join("; ")}` : "";
|
||||||
|
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const token of [row.registrator, ...row.analytics]) {
|
||||||
|
const normalized = token.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/договор|contract|дог\./i.test(normalized)) {
|
||||||
|
candidates.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqueStrings(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeFactualReply(
|
||||||
|
intent: AddressIntent,
|
||||||
|
rows: ComposeStageRow[]
|
||||||
|
): { responseType: AddressResponseType; text: string } {
|
||||||
|
if (intent === "account_balance_snapshot") {
|
||||||
|
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||||
|
const lines = [
|
||||||
|
"Адресный срез по счету собран (по движениям live MCP).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||||
|
...formatTopRows(rows, 4)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_SUMMARY",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "documents_forming_balance") {
|
||||||
|
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||||
|
const lines = [
|
||||||
|
"Собран drilldown документов, формирующих остаток по счету на указанную дату.",
|
||||||
|
`Документных строк отобрано: ${rows.length}.`,
|
||||||
|
`Сумма по отобранным движениям: ${movementSum}.`,
|
||||||
|
...formatTopRows(rows, 8),
|
||||||
|
"Можно уточнить выборку по контрагенту, договору или периоду."
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "list_open_contracts") {
|
||||||
|
const contracts = contractCandidatesFromRows(rows);
|
||||||
|
const lines = [
|
||||||
|
"Собраны кандидаты по незакрытым договорным позициям (по live движениям 60/62/76).",
|
||||||
|
`Строк движения: ${rows.length}.`,
|
||||||
|
`Договорных кандидатов: ${contracts.length}.`
|
||||||
|
];
|
||||||
|
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "open_items_by_counterparty_or_contract") {
|
||||||
|
const lines = [
|
||||||
|
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
...formatTopRows(rows, 6)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "list_documents_by_counterparty") {
|
||||||
|
const lines = [
|
||||||
|
"Собран список документов по контрагенту (live address lane).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
...formatTopRows(rows, rows.length)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "bank_operations_by_counterparty") {
|
||||||
|
const lines = [
|
||||||
|
"Собран список банковских операций по контрагенту (live address lane).",
|
||||||
|
`Строк отобрано: ${rows.length}.`,
|
||||||
|
...formatTopRows(rows, rows.length)
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
intent === "list_payables_counterparties"
|
||||||
|
? "Срез обязательств (payables) собран по движениям с account scope 60/76."
|
||||||
|
: intent === "list_receivables_counterparties"
|
||||||
|
? "Срез требований (receivables) собран по движениям с account scope 62/76."
|
||||||
|
: "Срез адресного запроса собран.";
|
||||||
|
|
||||||
|
const lines = [title, `Строк отобрано: ${rows.length}.`, ...formatTopRows(rows, 6)];
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_LIST",
|
||||||
|
text: lines.join("\n")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferReplyType(responseType: AddressResponseType): "factual" | "partial_coverage" {
|
||||||
|
if (responseType === "FACTUAL_LIST" || responseType === "FACTUAL_SUMMARY") {
|
||||||
|
return "factual";
|
||||||
|
}
|
||||||
|
return "partial_coverage";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
import type {
|
||||||
|
AddressFilterSet,
|
||||||
|
AddressIntent,
|
||||||
|
AddressIntentResolution,
|
||||||
|
AddressModeDetection,
|
||||||
|
AddressQueryShapeDetection
|
||||||
|
} from "../../types/addressQuery";
|
||||||
|
import { detectAddressQuestionMode } from "../addressQueryClassifier";
|
||||||
|
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
|
||||||
|
import { resolveAddressIntent } from "../addressIntentResolver";
|
||||||
|
import { extractAddressFilters } from "../addressFilterExtractor";
|
||||||
|
|
||||||
|
export interface AddressFollowupContext {
|
||||||
|
previous_intent?: AddressIntent;
|
||||||
|
previous_filters?: AddressFilterSet;
|
||||||
|
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||||
|
previous_anchor_value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressDecomposeStageResult {
|
||||||
|
mode: AddressModeDetection;
|
||||||
|
shape: AddressQueryShapeDetection;
|
||||||
|
intent: AddressIntentResolution;
|
||||||
|
filters: {
|
||||||
|
extracted_filters: AddressFilterSet;
|
||||||
|
missing_required_filters: string[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
baseReasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
|
||||||
|
return (
|
||||||
|
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||||
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonEmptyString(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAllTimeHint(text: string): boolean {
|
||||||
|
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(
|
||||||
|
String(text ?? "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||||
|
const normalized = String(text ?? "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasAllTimeHint(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that)/iu.test(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalized.split(/\s+/).filter(Boolean).length <= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFollowupFilters(
|
||||||
|
current: AddressFilterSet,
|
||||||
|
intent: AddressIntent,
|
||||||
|
userMessage: string,
|
||||||
|
followupContext: AddressFollowupContext | null
|
||||||
|
): { filters: AddressFilterSet; reasons: string[] } {
|
||||||
|
const merged: AddressFilterSet = { ...current };
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (!followupContext) {
|
||||||
|
return { filters: merged, reasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = followupContext.previous_filters ?? {};
|
||||||
|
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
|
||||||
|
const previousCounterparty = toNonEmptyString(previous.counterparty);
|
||||||
|
const previousContract = toNonEmptyString(previous.contract);
|
||||||
|
const previousAccount = toNonEmptyString(previous.account);
|
||||||
|
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||||
|
|
||||||
|
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
|
||||||
|
if (!toNonEmptyString(merged.counterparty)) {
|
||||||
|
const inheritedCounterparty =
|
||||||
|
previousCounterparty ??
|
||||||
|
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||||
|
if (inheritedCounterparty) {
|
||||||
|
merged.counterparty = inheritedCounterparty;
|
||||||
|
reasons.push("counterparty_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||||
|
if (!toNonEmptyString(merged.account)) {
|
||||||
|
const inheritedAccount =
|
||||||
|
previousAccount ??
|
||||||
|
(followupContext.previous_anchor_type === "account" ? previousAnchorValue : null);
|
||||||
|
if (inheritedAccount) {
|
||||||
|
merged.account = inheritedAccount;
|
||||||
|
reasons.push("account_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
|
||||||
|
if (!toNonEmptyString(merged.contract)) {
|
||||||
|
const inheritedContract =
|
||||||
|
previousContract ??
|
||||||
|
(followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||||
|
if (inheritedContract) {
|
||||||
|
merged.contract = inheritedContract;
|
||||||
|
reasons.push("contract_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!toNonEmptyString(merged.counterparty)) {
|
||||||
|
const inheritedCounterparty =
|
||||||
|
previousCounterparty ??
|
||||||
|
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||||
|
if (inheritedCounterparty) {
|
||||||
|
merged.counterparty = inheritedCounterparty;
|
||||||
|
reasons.push("counterparty_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allTimeRequested) {
|
||||||
|
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
|
||||||
|
delete merged.period_from;
|
||||||
|
delete merged.period_to;
|
||||||
|
reasons.push("period_cleared_by_all_time_followup");
|
||||||
|
}
|
||||||
|
return { filters: merged, reasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHasPeriod = hasExplicitPeriodWindow(merged);
|
||||||
|
const previousHasPeriod = hasExplicitPeriodWindow(previous);
|
||||||
|
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
|
||||||
|
if (toNonEmptyString(previous.period_from)) {
|
||||||
|
merged.period_from = previous.period_from;
|
||||||
|
}
|
||||||
|
if (toNonEmptyString(previous.period_to)) {
|
||||||
|
merged.period_to = previous.period_to;
|
||||||
|
}
|
||||||
|
reasons.push("period_from_followup_context");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filters: merged, reasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFilterSet): string[] {
|
||||||
|
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
|
||||||
|
account_balance_snapshot: ["account", "as_of_date"],
|
||||||
|
documents_forming_balance: ["account", "as_of_date"],
|
||||||
|
list_documents_by_counterparty: ["counterparty"],
|
||||||
|
bank_operations_by_counterparty: ["counterparty"]
|
||||||
|
};
|
||||||
|
const required = requiredByIntent[intent] ?? [];
|
||||||
|
return required.filter((key) => {
|
||||||
|
const value = filters[key];
|
||||||
|
return value === undefined || value === null || String(value).trim() === "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveIntentWithFollowupContext(
|
||||||
|
detectedIntent: AddressIntentResolution,
|
||||||
|
userMessage: string,
|
||||||
|
followupContext: AddressFollowupContext | null
|
||||||
|
): AddressIntentResolution {
|
||||||
|
if (!followupContext || !followupContext.previous_intent) {
|
||||||
|
return detectedIntent;
|
||||||
|
}
|
||||||
|
if (detectedIntent.intent !== "unknown") {
|
||||||
|
return detectedIntent;
|
||||||
|
}
|
||||||
|
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||||
|
return detectedIntent;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
intent: followupContext.previous_intent,
|
||||||
|
confidence: "low",
|
||||||
|
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAddressDecomposeStage(
|
||||||
|
userMessage: string,
|
||||||
|
followupContext: AddressFollowupContext | null
|
||||||
|
): AddressDecomposeStageResult | null {
|
||||||
|
const detectedMode = detectAddressQuestionMode(userMessage);
|
||||||
|
const mode =
|
||||||
|
detectedMode.mode === "address_query"
|
||||||
|
? detectedMode
|
||||||
|
: followupContext && hasAddressFollowupContextSignal(userMessage)
|
||||||
|
? {
|
||||||
|
mode: "address_query" as const,
|
||||||
|
confidence: "medium" as const,
|
||||||
|
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
|
||||||
|
}
|
||||||
|
: detectedMode;
|
||||||
|
if (mode.mode !== "address_query") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = classifyAddressQueryShape(userMessage);
|
||||||
|
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedIntent = resolveAddressIntent(userMessage);
|
||||||
|
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
|
||||||
|
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
|
||||||
|
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
|
||||||
|
const filters = {
|
||||||
|
extracted_filters: followupMerged.filters,
|
||||||
|
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
|
||||||
|
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
|
||||||
|
};
|
||||||
|
const followupContextApplied =
|
||||||
|
Boolean(followupContext) &&
|
||||||
|
(mode.reasons.includes("address_mode_from_followup_context") ||
|
||||||
|
intent.reasons.includes("intent_from_followup_context") ||
|
||||||
|
followupMerged.reasons.length > 0);
|
||||||
|
const baseReasons = [
|
||||||
|
...mode.reasons,
|
||||||
|
...shape.reasons,
|
||||||
|
...intent.reasons,
|
||||||
|
...followupMerged.reasons,
|
||||||
|
...(followupContextApplied ? ["address_followup_context_applied"] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
shape,
|
||||||
|
intent,
|
||||||
|
filters,
|
||||||
|
baseReasons
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import type { AddressFilterSet, AddressIntent } from "../../types/addressQuery";
|
||||||
|
|
||||||
|
const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||||
|
"ооо",
|
||||||
|
"ао",
|
||||||
|
"зао",
|
||||||
|
"ип",
|
||||||
|
"llc",
|
||||||
|
"ltd",
|
||||||
|
"company",
|
||||||
|
"компания",
|
||||||
|
"контрагент",
|
||||||
|
"counterparty",
|
||||||
|
"по",
|
||||||
|
"by"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface AnchorResolutionDebug {
|
||||||
|
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||||
|
anchor_value_raw: string | null;
|
||||||
|
anchor_value_resolved: string | null;
|
||||||
|
resolver_confidence: "high" | "medium" | "low" | null;
|
||||||
|
ambiguity_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveStageRow {
|
||||||
|
registrator: string;
|
||||||
|
account_dt: string | null;
|
||||||
|
account_kt: string | null;
|
||||||
|
analytics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transliterateCyrillicToLatin(value: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
а: "a",
|
||||||
|
б: "b",
|
||||||
|
в: "v",
|
||||||
|
г: "g",
|
||||||
|
д: "d",
|
||||||
|
е: "e",
|
||||||
|
ё: "e",
|
||||||
|
ж: "zh",
|
||||||
|
з: "z",
|
||||||
|
и: "i",
|
||||||
|
й: "y",
|
||||||
|
к: "k",
|
||||||
|
л: "l",
|
||||||
|
м: "m",
|
||||||
|
н: "n",
|
||||||
|
о: "o",
|
||||||
|
п: "p",
|
||||||
|
р: "r",
|
||||||
|
с: "s",
|
||||||
|
т: "t",
|
||||||
|
у: "u",
|
||||||
|
ф: "f",
|
||||||
|
х: "h",
|
||||||
|
ц: "ts",
|
||||||
|
ч: "ch",
|
||||||
|
ш: "sh",
|
||||||
|
щ: "sch",
|
||||||
|
ъ: "",
|
||||||
|
ы: "y",
|
||||||
|
ь: "",
|
||||||
|
э: "e",
|
||||||
|
ю: "yu",
|
||||||
|
я: "ya"
|
||||||
|
};
|
||||||
|
let out = "";
|
||||||
|
for (const char of String(value ?? "").toLowerCase()) {
|
||||||
|
out += map[char] ?? char;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value: string): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е")
|
||||||
|
.replace(/[^a-zа-я0-9]+/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeAnchor(value: string): string[] {
|
||||||
|
return normalizeSearchText(value)
|
||||||
|
.split(" ")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAnchorText(searchable: string, anchor: string): boolean {
|
||||||
|
const searchableNormalized = normalizeSearchText(searchable);
|
||||||
|
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||||
|
const tokens = tokenizeAnchor(anchor);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
const direct = normalizeSearchText(anchor);
|
||||||
|
if (!direct) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
|
||||||
|
}
|
||||||
|
return tokens.every((token) => {
|
||||||
|
const tokenLatin = transliterateCyrillicToLatin(token);
|
||||||
|
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
values
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilterSet): AnchorResolutionDebug {
|
||||||
|
const account = typeof filters.account === "string" ? filters.account.trim() : "";
|
||||||
|
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
|
||||||
|
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
||||||
|
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
||||||
|
|
||||||
|
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||||
|
if (account) {
|
||||||
|
return {
|
||||||
|
anchor_type: "account",
|
||||||
|
anchor_value_raw: account,
|
||||||
|
anchor_value_resolved: account,
|
||||||
|
resolver_confidence: "high",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counterparty) {
|
||||||
|
return {
|
||||||
|
anchor_type: "counterparty",
|
||||||
|
anchor_value_raw: counterparty,
|
||||||
|
anchor_value_resolved: counterparty,
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract) {
|
||||||
|
return {
|
||||||
|
anchor_type: "contract",
|
||||||
|
anchor_value_raw: contract,
|
||||||
|
anchor_value_resolved: contract,
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documentRef) {
|
||||||
|
return {
|
||||||
|
anchor_type: "document_ref",
|
||||||
|
anchor_value_raw: documentRef,
|
||||||
|
anchor_value_resolved: documentRef,
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
anchor_type: "unknown",
|
||||||
|
anchor_value_raw: null,
|
||||||
|
anchor_value_resolved: null,
|
||||||
|
resolver_confidence: "low",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: ResolveStageRow[]): AnchorResolutionDebug {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
|
||||||
|
if (!needleRaw) {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
const candidates = uniqueStrings(
|
||||||
|
rows
|
||||||
|
.flatMap((row) => row.analytics)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
return {
|
||||||
|
...anchor,
|
||||||
|
anchor_value_resolved: candidates[0],
|
||||||
|
resolver_confidence: anchor.resolver_confidence === "high" ? "high" : "medium",
|
||||||
|
ambiguity_count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...anchor,
|
||||||
|
anchor_value_resolved: candidates[0],
|
||||||
|
resolver_confidence: "low",
|
||||||
|
ambiguity_count: candidates.length - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1693,8 +1693,9 @@ function buildAddressCoverageReport() {
|
||||||
out_of_scope_requirements: []
|
out_of_scope_requirements: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function buildAddressDebugPayload(addressDebug) {
|
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
|
||||||
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
|
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
|
||||||
|
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
|
||||||
return {
|
return {
|
||||||
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
|
trace_id: `address-${(0, nanoid_1.nanoid)(10)}`,
|
||||||
prompt_version: "address_query_runtime_v1",
|
prompt_version: "address_query_runtime_v1",
|
||||||
|
|
@ -1752,12 +1753,204 @@ function buildAddressDebugPayload(addressDebug) {
|
||||||
runtime_readiness: addressDebug.runtime_readiness,
|
runtime_readiness: addressDebug.runtime_readiness,
|
||||||
limited_reason_category: addressDebug.limited_reason_category,
|
limited_reason_category: addressDebug.limited_reason_category,
|
||||||
response_type: addressDebug.response_type,
|
response_type: addressDebug.response_type,
|
||||||
|
execution_lane: "address_query",
|
||||||
|
llm_decomposition_applied: Boolean(llmMeta?.applied),
|
||||||
|
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
|
||||||
|
llm_provider_used: llmMeta?.provider ?? null,
|
||||||
|
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
|
||||||
|
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
|
||||||
|
llm_decomposition_reason: llmMeta?.reason ?? null,
|
||||||
answer_structure_v11: null,
|
answer_structure_v11: null,
|
||||||
investigation_state_snapshot: null,
|
investigation_state_snapshot: null,
|
||||||
normalized: null,
|
normalized: null,
|
||||||
normalizer_output: null
|
normalizer_output: llmMeta?.traceId
|
||||||
|
? {
|
||||||
|
trace_id: llmMeta.traceId,
|
||||||
|
prompt_version: "normalizer_v2_0_2",
|
||||||
|
applied: Boolean(llmMeta?.applied),
|
||||||
|
effective_message: llmMeta?.effectiveMessage ?? null
|
||||||
|
}
|
||||||
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function toNonEmptyString(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
function readAddressFilterString(addressDebug, key) {
|
||||||
|
const filters = addressDebug?.extracted_filters;
|
||||||
|
if (!filters || typeof filters !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toNonEmptyString(filters[key]);
|
||||||
|
}
|
||||||
|
function findLastAddressAssistantDebug(items) {
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item || item.role !== "assistant" || !item.debug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const debug = item.debug;
|
||||||
|
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
|
||||||
|
return debug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hasAddressFollowupContextSignal(userMessage) {
|
||||||
|
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hasReferentialPointer(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const shortFollowup = countTokens(text) <= 8;
|
||||||
|
if (shortFollowup && hasFollowupMarker(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function resolveAddressFollowupCarryoverContext(userMessage, items) {
|
||||||
|
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previousAddressDebug = findLastAddressAssistantDebug(items);
|
||||||
|
if (!previousAddressDebug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previousIntent = toNonEmptyString(previousAddressDebug.detected_intent);
|
||||||
|
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||||
|
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||||
|
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
||||||
|
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
||||||
|
readAddressFilterString(previousAddressDebug, "account") ??
|
||||||
|
readAddressFilterString(previousAddressDebug, "contract");
|
||||||
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
|
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||||
|
? { ...previousFiltersRaw }
|
||||||
|
: {};
|
||||||
|
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
followupContext: {
|
||||||
|
previous_intent: previousIntent ?? undefined,
|
||||||
|
previous_filters: previousFilters,
|
||||||
|
previous_anchor_type: previousAnchorType ?? undefined,
|
||||||
|
previous_anchor_value: previousAnchor
|
||||||
|
},
|
||||||
|
previousAddressIntent: previousIntent,
|
||||||
|
previousAddressAnchor: previousAnchor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function isAddressLlmPreDecomposeCandidate(userMessage) {
|
||||||
|
const text = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(?:\bдок\b|доки|документ|контрагент|договор|остаток|сч(?:е|ё)т|банк|выписк|платеж|оплат|поступлен|реализац|сверк|взаиморасч|кто\s+должен|show|list|documents?|counterparty|contract|account|balance|bank\s+operations?)/i.test(text);
|
||||||
|
}
|
||||||
|
function extractAddressQuestionFromNormalized(normalized) {
|
||||||
|
if (!normalized || typeof normalized !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const source = normalized;
|
||||||
|
const fragments = Array.isArray(source.fragments) ? source.fragments : [];
|
||||||
|
for (const item of fragments) {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fragment = item;
|
||||||
|
const domainRelevance = String(fragment.domain_relevance ?? "").trim().toLowerCase();
|
||||||
|
if (domainRelevance === "out_of_scope") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
|
||||||
|
if (readiness === "no_route") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
|
||||||
|
const rawText = toNonEmptyString(fragment.raw_fragment_text);
|
||||||
|
const candidate = compactWhitespace(normalizedText ?? rawText ?? "");
|
||||||
|
if (candidate.length >= 3 && candidate.length <= 500) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
|
||||||
|
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
|
||||||
|
const baseMeta = {
|
||||||
|
attempted: false,
|
||||||
|
applied: false,
|
||||||
|
provider,
|
||||||
|
traceId: null,
|
||||||
|
effectiveMessage: userMessage,
|
||||||
|
reason: "not_attempted"
|
||||||
|
};
|
||||||
|
if (Boolean(payload?.useMock)) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
reason: "skipped_in_mock"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isAddressLlmPreDecomposeCandidate(userMessage)) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
reason: "not_address_like"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalizePayload = {
|
||||||
|
llmProvider: payload?.llmProvider,
|
||||||
|
apiKey: payload?.apiKey,
|
||||||
|
model: payload?.model,
|
||||||
|
baseUrl: payload?.baseUrl,
|
||||||
|
temperature: 0,
|
||||||
|
maxOutputTokens: payload?.maxOutputTokens,
|
||||||
|
promptVersion: "normalizer_v2_0_2",
|
||||||
|
userQuestion: userMessage,
|
||||||
|
context: payload?.context,
|
||||||
|
useMock: Boolean(payload?.useMock),
|
||||||
|
retryPolicy: "single-pass-strict"
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const normalized = await normalizerService.normalize(normalizePayload);
|
||||||
|
const candidate = extractAddressQuestionFromNormalized(normalized?.normalized);
|
||||||
|
if (!normalized?.ok || !candidate) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
attempted: true,
|
||||||
|
traceId: normalized?.trace_id ?? null,
|
||||||
|
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
|
const candidateCompact = compactWhitespace(candidate.toLowerCase());
|
||||||
|
const applied = sourceCompact !== candidateCompact;
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
applied,
|
||||||
|
provider,
|
||||||
|
traceId: normalized?.trace_id ?? null,
|
||||||
|
effectiveMessage: applied ? candidate : userMessage,
|
||||||
|
reason: applied ? "normalized_fragment_applied" : "normalized_fragment_same"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return {
|
||||||
|
...baseMeta,
|
||||||
|
attempted: true,
|
||||||
|
reason: `error:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AssistantService {
|
export class AssistantService {
|
||||||
normalizerService;
|
normalizerService;
|
||||||
sessions;
|
sessions;
|
||||||
|
|
@ -1789,10 +1982,8 @@ export class AssistantService {
|
||||||
debug: null
|
debug: null
|
||||||
};
|
};
|
||||||
this.sessions.appendItem(sessionId, userItem);
|
this.sessions.appendItem(sessionId, userItem);
|
||||||
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
|
||||||
const addressLane = await this.addressQueryService.tryHandle(userMessage);
|
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
|
||||||
if (addressLane?.handled) {
|
|
||||||
const debug = buildAddressDebugPayload(addressLane.debug);
|
|
||||||
const assistantItem = {
|
const assistantItem = {
|
||||||
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
|
|
@ -1820,6 +2011,15 @@ export class AssistantService {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
message_id: assistantItem.message_id,
|
message_id: assistantItem.message_id,
|
||||||
user_message: userMessage,
|
user_message: userMessage,
|
||||||
|
effective_address_user_message: effectiveAddressUserMessage,
|
||||||
|
address_followup_context_applied: Boolean(carryoverMeta),
|
||||||
|
address_followup_context_previous_intent: carryoverMeta?.previousAddressIntent ?? null,
|
||||||
|
address_followup_context_previous_anchor: carryoverMeta?.previousAddressAnchor ?? null,
|
||||||
|
address_llm_predecompose_attempted: Boolean(llmPreDecomposeMeta?.attempted),
|
||||||
|
address_llm_predecompose_applied: Boolean(llmPreDecomposeMeta?.applied),
|
||||||
|
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
|
||||||
|
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
|
||||||
|
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
|
||||||
detected_mode: addressLane.debug.detected_mode,
|
detected_mode: addressLane.debug.detected_mode,
|
||||||
query_shape: addressLane.debug.query_shape,
|
query_shape: addressLane.debug.query_shape,
|
||||||
detected_intent: addressLane.debug.detected_intent,
|
detected_intent: addressLane.debug.detected_intent,
|
||||||
|
|
@ -1863,6 +2063,31 @@ export class AssistantService {
|
||||||
debug,
|
debug,
|
||||||
conversation
|
conversation
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
|
||||||
|
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
|
||||||
|
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
|
||||||
|
: {
|
||||||
|
attempted: false,
|
||||||
|
applied: false,
|
||||||
|
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
|
||||||
|
traceId: null,
|
||||||
|
effectiveMessage: userMessage,
|
||||||
|
reason: "disabled_by_feature_flag"
|
||||||
|
};
|
||||||
|
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
|
||||||
|
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
|
||||||
|
if (primaryAddressLane?.handled) {
|
||||||
|
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose);
|
||||||
|
}
|
||||||
|
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
|
||||||
|
if (carryover?.followupContext) {
|
||||||
|
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
|
||||||
|
followupContext: carryover.followupContext
|
||||||
|
});
|
||||||
|
if (contextualAddressLane?.handled) {
|
||||||
|
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
|
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
|
||||||
|
|
@ -1879,12 +2104,13 @@ export class AssistantService {
|
||||||
usage: null
|
usage: null
|
||||||
};
|
};
|
||||||
const normalizePayload = {
|
const normalizePayload = {
|
||||||
|
llmProvider: payload.llmProvider,
|
||||||
apiKey: payload.apiKey,
|
apiKey: payload.apiKey,
|
||||||
model: payload.model,
|
model: payload.model,
|
||||||
baseUrl: payload.baseUrl,
|
baseUrl: payload.baseUrl,
|
||||||
temperature: payload.temperature,
|
temperature: payload.temperature,
|
||||||
maxOutputTokens: payload.maxOutputTokens,
|
maxOutputTokens: payload.maxOutputTokens,
|
||||||
promptVersion: payload.promptVersion ?? "normalizer_v2_0_2",
|
promptVersion: payload.promptVersion ?? "address_query_runtime_v1",
|
||||||
systemPrompt: payload.systemPrompt,
|
systemPrompt: payload.systemPrompt,
|
||||||
developerPrompt: payload.developerPrompt,
|
developerPrompt: payload.developerPrompt,
|
||||||
domainPrompt: payload.domainPrompt,
|
domainPrompt: payload.domainPrompt,
|
||||||
|
|
|
||||||
|
|
@ -1036,6 +1036,7 @@ export class NormalizerService {
|
||||||
public async normalize(payload: NormalizeRequestPayload): Promise<NormalizeResponsePayload> {
|
public async normalize(payload: NormalizeRequestPayload): Promise<NormalizeResponsePayload> {
|
||||||
const traceId = nanoid(14);
|
const traceId = nanoid(14);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const llmProvider = payload.llmProvider === "local" ? "local" : "openai";
|
||||||
const model = payload.model ?? DEFAULT_MODEL;
|
const model = payload.model ?? DEFAULT_MODEL;
|
||||||
const baseUrl = payload.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
|
const baseUrl = payload.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
|
||||||
const temperature = payload.temperature ?? DEFAULT_TEMPERATURE;
|
const temperature = payload.temperature ?? DEFAULT_TEMPERATURE;
|
||||||
|
|
@ -1072,6 +1073,7 @@ export class NormalizerService {
|
||||||
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
|
const apiKey = payload.apiKey ?? process.env.OPENAI_API_KEY;
|
||||||
const firstTry = await this.openaiClient.normalize(
|
const firstTry = await this.openaiClient.normalize(
|
||||||
{
|
{
|
||||||
|
llmProvider,
|
||||||
apiKey: String(apiKey ?? ""),
|
apiKey: String(apiKey ?? ""),
|
||||||
model,
|
model,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
@ -1118,6 +1120,7 @@ export class NormalizerService {
|
||||||
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
|
const retryMaxOutputTokens = computeRetryMaxOutputTokens(maxOutputTokens, rawModelResponse);
|
||||||
const retry = await this.openaiClient.normalize(
|
const retry = await this.openaiClient.normalize(
|
||||||
{
|
{
|
||||||
|
llmProvider,
|
||||||
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
|
||||||
model,
|
model,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
|
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
|
||||||
|
import type { LlmProvider } from "../types/normalizer";
|
||||||
import { ApiError } from "../utils/http";
|
import { ApiError } from "../utils/http";
|
||||||
|
|
||||||
export interface OpenAIRequestConfig {
|
export interface OpenAIRequestConfig {
|
||||||
|
llmProvider?: LlmProvider;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
@ -21,6 +23,22 @@ export interface OpenAIResponseEnvelope {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveProvider(config: OpenAIRequestConfig): LlmProvider {
|
||||||
|
return config.llmProvider === "local" ? "local" : "openai";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiKey(config: OpenAIRequestConfig): string {
|
||||||
|
const candidate = String(config.apiKey ?? "").trim();
|
||||||
|
if (candidate.length > 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (resolveProvider(config) === "local") {
|
||||||
|
// Local OpenAI-compatible servers often accept any token.
|
||||||
|
return "local-dev-token";
|
||||||
|
}
|
||||||
|
throw new ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
function extractUsage(raw: Record<string, unknown>): {
|
function extractUsage(raw: Record<string, unknown>): {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
|
|
@ -37,7 +55,7 @@ function extractUsage(raw: Record<string, unknown>): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOutputText(raw: Record<string, unknown>): string {
|
function extractOutputTextFromResponses(raw: Record<string, unknown>): string {
|
||||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||||
return raw.output_text;
|
return raw.output_text;
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +90,58 @@ function extractOutputText(raw: Record<string, unknown>): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOutputTextFromChatCompletions(raw: Record<string, unknown>): string {
|
||||||
|
const choices = raw.choices;
|
||||||
|
if (!Array.isArray(choices) || choices.length === 0) {
|
||||||
|
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
const first = choices[0];
|
||||||
|
if (!first || typeof first !== "object") {
|
||||||
|
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
const message = (first as Record<string, unknown>).message;
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
const content = (message as Record<string, unknown>).content;
|
||||||
|
if (typeof content === "string" && content.trim().length > 0) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const textParts = content
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const block = item as Record<string, unknown>;
|
||||||
|
return typeof block.text === "string" ? block.text : "";
|
||||||
|
})
|
||||||
|
.filter((item) => item.trim().length > 0);
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
return textParts.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallbackToChatCompletions(error: unknown): boolean {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (error.code !== "OPENAI_REQUEST_FAILED") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const details = (error.details ?? {}) as Record<string, unknown>;
|
||||||
|
const status = Number(details.status ?? 0);
|
||||||
|
if ([404, 405, 501].includes(status)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const message = String(error.message ?? "").toLowerCase();
|
||||||
|
return message.includes("/responses") || message.includes("responses");
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
||||||
|
|
@ -88,19 +157,57 @@ function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"
|
||||||
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
|
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenAIResponsesClient {
|
function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
|
||||||
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
|
const base = (config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
||||||
const payload = {
|
const provider = resolveProvider(config);
|
||||||
model: config.model,
|
if (provider !== "local") {
|
||||||
input: [
|
return [base];
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "input_text", text: "ping" }]
|
|
||||||
}
|
}
|
||||||
],
|
const hasVersionSuffix = /\/v\d+$/i.test(base);
|
||||||
|
if (hasVersionSuffix) {
|
||||||
|
return [base];
|
||||||
|
}
|
||||||
|
return Array.from(new Set([base, `${base}/v1`]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenAIResponsesClient {
|
||||||
|
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
|
||||||
|
const payload = await this.getModels(config);
|
||||||
|
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||||
|
const ids = data
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String((item as Record<string, unknown>).id ?? "").trim();
|
||||||
|
})
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
|
||||||
|
return Array.from(new Set(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
|
||||||
|
const provider = resolveProvider(config);
|
||||||
|
if (provider === "local") {
|
||||||
|
try {
|
||||||
|
await this.getModels(config);
|
||||||
|
} catch {
|
||||||
|
// Some local providers do not expose /models consistently; fallback to a tiny chat call.
|
||||||
|
await this.postChatCompletions(config, {
|
||||||
|
model: config.model,
|
||||||
|
messages: [{ role: "user", content: "ping" }],
|
||||||
|
max_tokens: 4,
|
||||||
|
temperature: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, model: config.model };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.postResponses(config, {
|
||||||
|
model: config.model,
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }],
|
||||||
max_output_tokens: 16
|
max_output_tokens: 16
|
||||||
};
|
});
|
||||||
await this.post(config, payload);
|
|
||||||
return { ok: true, model: config.model };
|
return { ok: true, model: config.model };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +236,7 @@ export class OpenAIResponsesClient {
|
||||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||||
: prompt.developerPrompt;
|
: prompt.developerPrompt;
|
||||||
|
|
||||||
const payload = {
|
const responsesPayload = {
|
||||||
model: config.model,
|
model: config.model,
|
||||||
temperature: config.temperature ?? 0,
|
temperature: config.temperature ?? 0,
|
||||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||||
|
|
@ -147,7 +254,7 @@ export class OpenAIResponsesClient {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "input_text",
|
type: "input_text",
|
||||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -162,45 +269,144 @@ export class OpenAIResponsesClient {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const raw = await this.post(config, payload);
|
const provider = resolveProvider(config);
|
||||||
const outputText = extractOutputText(raw);
|
if (provider === "openai") {
|
||||||
|
const raw = await this.postResponses(config, responsesPayload);
|
||||||
return {
|
return {
|
||||||
raw,
|
raw,
|
||||||
outputText,
|
outputText: extractOutputTextFromResponses(raw),
|
||||||
usage: extractUsage(raw)
|
usage: extractUsage(raw)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async post(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
// local provider: prefer /responses if available, fallback to /chat/completions
|
||||||
if (!config.apiKey || config.apiKey.trim().length < 10) {
|
try {
|
||||||
throw new ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
|
const raw = await this.postResponses(config, responsesPayload);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
outputText: extractOutputTextFromResponses(raw),
|
||||||
|
usage: extractUsage(raw)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldFallbackToChatCompletions(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${(config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
|
const chatPayload = {
|
||||||
const response = await fetch(url, {
|
model: config.model,
|
||||||
method: "POST",
|
temperature: config.temperature ?? 0,
|
||||||
headers: {
|
max_tokens: config.maxOutputTokens ?? 700,
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
response_format: { type: "json_object" },
|
||||||
"Content-Type": "application/json"
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `${prompt.systemPrompt}\n\n${developerPrompt}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
{
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
`${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` +
|
||||||
|
`Return only JSON that matches schema: ${schemaName}.`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = await this.postChatCompletions(config, chatPayload);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
outputText: extractOutputTextFromChatCompletions(raw),
|
||||||
|
usage: extractUsage(raw)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getModels(config: OpenAIRequestConfig): Promise<Record<string, unknown>> {
|
||||||
|
return this.requestJson(config, "/models", "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postResponses(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||||
|
return this.requestJson(config, "/responses", "POST", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postChatCompletions(
|
||||||
|
config: OpenAIRequestConfig,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return this.requestJson(config, "/chat/completions", "POST", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestJson(
|
||||||
|
config: OpenAIRequestConfig,
|
||||||
|
routePath: string,
|
||||||
|
method: "GET" | "POST",
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const apiKey = resolveApiKey(config);
|
||||||
|
const baseCandidates = buildBaseUrlCandidates(config);
|
||||||
|
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
|
||||||
|
let lastNetworkError: unknown = null;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
};
|
||||||
|
if (method === "POST") {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < baseCandidates.length; index += 1) {
|
||||||
|
const base = baseCandidates[index];
|
||||||
|
const isLastCandidate = index === baseCandidates.length - 1;
|
||||||
|
const url = `${base}${routePath}`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastNetworkError = error;
|
||||||
|
if (!isLastCandidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||||
|
route: routePath,
|
||||||
|
url,
|
||||||
|
reason: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let data: Record<string, unknown>;
|
let data: Record<string, unknown> = {};
|
||||||
|
if (text.trim().length > 0) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text) as Record<string, unknown>;
|
data = JSON.parse(text) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
|
if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, {
|
||||||
|
route: routePath,
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
body: text.slice(0, 500)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
"OPENAI_REQUEST_FAILED",
|
"OPENAI_REQUEST_FAILED",
|
||||||
String(errorObj.message ?? `OpenAI request failed with status ${response.status}`),
|
String(errorObj.message ?? `Model endpoint failed: ${response.status}`),
|
||||||
response.status,
|
response.status,
|
||||||
{
|
{
|
||||||
|
route: routePath,
|
||||||
|
url,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
type: errorObj.type ?? null,
|
type: errorObj.type ?? null,
|
||||||
code: errorObj.code ?? null
|
code: errorObj.code ?? null
|
||||||
|
|
@ -210,4 +416,10 @@ export class OpenAIResponsesClient {
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||||
|
route: routePath,
|
||||||
|
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@ export interface AssistantMessageRequestPayload {
|
||||||
user_message?: string;
|
user_message?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
mode?: "assistant" | string;
|
mode?: "assistant" | string;
|
||||||
|
llmProvider?: NormalizeRequestPayload["llmProvider"];
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
@ -370,6 +371,13 @@ export interface AssistantDebugPayload {
|
||||||
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
|
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
|
||||||
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
|
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
|
||||||
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
|
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
|
||||||
|
execution_lane?: "address_query" | "deep_analysis";
|
||||||
|
llm_decomposition_applied?: boolean;
|
||||||
|
llm_decomposition_attempted?: boolean;
|
||||||
|
llm_provider_used?: "openai" | "local" | null;
|
||||||
|
llm_decomposition_trace_id?: string | null;
|
||||||
|
llm_decomposition_effective_message?: string | null;
|
||||||
|
llm_decomposition_reason?: string | null;
|
||||||
business_scope_raw?: string[];
|
business_scope_raw?: string[];
|
||||||
business_scope_resolved?: string[];
|
business_scope_resolved?: string[];
|
||||||
company_grounding_applied?: boolean;
|
company_grounding_applied?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export type PromptVersion =
|
||||||
| "normalizer_v2_0_2";
|
| "normalizer_v2_0_2";
|
||||||
|
|
||||||
export type EvalRunMode = "standard" | "single-pass-strict";
|
export type EvalRunMode = "standard" | "single-pass-strict";
|
||||||
|
export type LlmProvider = "openai" | "local";
|
||||||
|
|
||||||
export interface NormalizedQueryV1 {
|
export interface NormalizedQueryV1 {
|
||||||
schema_version: "normalized_query_v1";
|
schema_version: "normalized_query_v1";
|
||||||
|
|
@ -235,6 +236,7 @@ export type RouteHintSummary = RouteHintSummaryV1 | RouteHintSummaryV2;
|
||||||
export type NormalizedPayload = NormalizedQueryV1 | NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
|
export type NormalizedPayload = NormalizedQueryV1 | NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
|
||||||
|
|
||||||
export interface NormalizeRequestPayload {
|
export interface NormalizeRequestPayload {
|
||||||
|
llmProvider?: LlmProvider;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { executeAddressMcpQuery } from "../src/services/addressMcpClient";
|
||||||
|
|
||||||
|
const ORIGINAL_FETCH = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = ORIGINAL_FETCH;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("address MCP encoding repair", () => {
|
||||||
|
it("repairs UTF-8/CP1251 mojibake in object rows", async () => {
|
||||||
|
const payload = {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
"Период": "2020-07-30T12:00:00Z",
|
||||||
|
"Регистратор": "Поступление на расчетный счет 0001",
|
||||||
|
"СчетДт": "51",
|
||||||
|
"СчетКт": "62.01",
|
||||||
|
"РЎСѓРјРјР°": "20000",
|
||||||
|
"Контрагент": "Группа СВК"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await executeAddressMcpQuery({
|
||||||
|
query: "SELECT 1",
|
||||||
|
limit: 20,
|
||||||
|
account_scope: []
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.fetched_rows).toBe(1);
|
||||||
|
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
|
||||||
|
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { detectAddressQuestionMode } from "../src/services/addressQueryClassifier";
|
||||||
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
|
||||||
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
|
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
|
||||||
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||||
|
|
@ -21,6 +22,16 @@ describe("address query shape classifier", () => {
|
||||||
const result = classifyAddressQueryShape("who owes us and who we owe today?");
|
const result = classifyAddressQueryShape("who owes us and who we owe today?");
|
||||||
expect(result.shape).toBe("COMPOUND_FACTUAL_QUERY");
|
expect(result.shape).toBe("COMPOUND_FACTUAL_QUERY");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps company lookup phrasing in address lane", () => {
|
||||||
|
const result = detectAddressQuestionMode("какие компании есть в базе");
|
||||||
|
expect(result.mode).toBe("address_query");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps loose by-anchor follow-up phrasing in address lane", () => {
|
||||||
|
const result = detectAddressQuestionMode("за любой период есть что-то по свк?");
|
||||||
|
expect(result.mode).toBe("address_query");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("address intent resolver expansion (M2.3a)", () => {
|
describe("address intent resolver expansion (M2.3a)", () => {
|
||||||
|
|
@ -38,6 +49,41 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||||
const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31");
|
const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31");
|
||||||
expect(result.intent).toBe("documents_forming_balance");
|
expect(result.intent).toBe("documents_forming_balance");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves documents by company phrase as counterparty intent", () => {
|
||||||
|
const result = resolveAddressIntent("Какие документы доступны по компании СВК за 2021 год?");
|
||||||
|
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves bank operations by supplier phrase", () => {
|
||||||
|
const result = resolveAddressIntent("Покажи платежи по поставщику Альфа за июль 2020");
|
||||||
|
expect(result.intent).toBe("bank_operations_by_counterparty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves documents by client phrase", () => {
|
||||||
|
const result = resolveAddressIntent("Выведи документы по клиенту Бета за 2020-07");
|
||||||
|
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves short slang docs phrase with loose by-anchor", () => {
|
||||||
|
const result = resolveAddressIntent("какие доки есть по свк за 2021");
|
||||||
|
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves typo slang docs phrase with implicit anchor", () => {
|
||||||
|
const result = resolveAddressIntent("свк доки за 20год покеж");
|
||||||
|
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves noisy docs phrase with slang tail", () => {
|
||||||
|
const result = resolveAddressIntent("свк 20 год - покажи доки плс");
|
||||||
|
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves loose by-anchor follow-up as documents by counterparty fallback", () => {
|
||||||
|
const result = resolveAddressIntent("за любой период есть что-то по свк?");
|
||||||
|
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("address filter extraction for balance drilldown", () => {
|
describe("address filter extraction for balance drilldown", () => {
|
||||||
|
|
@ -68,6 +114,123 @@ describe("address filter extraction for balance drilldown", () => {
|
||||||
expect(result.extracted_filters.period_to).toBeUndefined();
|
expect(result.extracted_filters.period_to).toBeUndefined();
|
||||||
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts counterparty from company phrase and derives year period", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"Какие документы доступны по компании СВК за 2021 год?",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("СВК");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2021-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2021-12-31");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts counterparty from supplier phrase and derives month period", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"Покажи документы по поставщику Альфа за июль 2020",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("Альфа");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2020-07-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2020-07-31");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_month_phrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats 'за весь период' as all-time hint and does not force 90-day default", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"Покажи банковские операции по клиенту Бета за весь период",
|
||||||
|
"bank_operations_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("Бета");
|
||||||
|
expect(result.extracted_filters.period_from).toBeUndefined();
|
||||||
|
expect(result.extracted_filters.period_to).toBeUndefined();
|
||||||
|
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts loose by-anchor and year period for short slang docs phrase", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"какие доки есть по свк за 2021",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2021-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2021-12-31");
|
||||||
|
expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts implicit counterparty and short-year period for typo slang docs phrase", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"свк доки за 20год покеж",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||||
|
expect(result.warnings).toContain("counterparty_anchor_derived_from_implicit_phrase");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"свк 20 год - покажи доки плс",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||||
|
expect(result.warnings).toContain("counterparty_anchor_derived_from_free_text_heuristic");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||||
|
expect(result.extracted_filters.counterparty).not.toBe("плс");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts explicit year range period from phrase", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"Какие документы по СВК за 2000 - 2025 год?",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("СВК");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2000-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2025-12-31");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts multiline year range period from phrase", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"Какие документы по СВК за 2000 - 2025\n год?",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("СВК");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2000-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2025-12-31");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
|
||||||
|
expect(result.warnings).not.toContain("period_derived_from_year_phrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts russian year range period from 'с ... по ...' phrase", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"какие есть доки по свк с 2020 по 2025 год",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||||
|
expect(result.extracted_filters.period_to).toBe("2025-12-31");
|
||||||
|
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
|
||||||
|
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats 'за любой период' as all-time hint and keeps loose by-anchor", () => {
|
||||||
|
const result = extractAddressFilters(
|
||||||
|
"за любой период есть что-то по свк?",
|
||||||
|
"list_documents_by_counterparty"
|
||||||
|
);
|
||||||
|
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result.extracted_filters.period_from).toBeUndefined();
|
||||||
|
expect(result.extracted_filters.period_to).toBeUndefined();
|
||||||
|
expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase");
|
||||||
|
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("address query limited taxonomy and stage diagnostics", () => {
|
describe("address query limited taxonomy and stage diagnostics", () => {
|
||||||
|
|
@ -93,7 +256,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||||
const service = new AddressQueryService();
|
const service = new AddressQueryService();
|
||||||
const result = await service.tryHandle("which documents form balance for account 62 as of 2020-07-31");
|
const result = await service.tryHandle("which documents form balance for account 62 as of 2020-07-31");
|
||||||
expect(result?.handled).toBe(true);
|
expect(result?.handled).toBe(true);
|
||||||
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
|
expect(["LIMITED_WITH_REASON", "FACTUAL_LIST"]).toContain(result?.response_type);
|
||||||
|
|
||||||
expect(result?.debug.anchor_type).toBe("account");
|
expect(result?.debug.anchor_type).toBe("account");
|
||||||
expect(result?.debug.rows_fetched).toBeTypeOf("number");
|
expect(result?.debug.rows_fetched).toBeTypeOf("number");
|
||||||
|
|
@ -108,6 +271,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||||
expect(result?.debug.match_failure_stage).toBeDefined();
|
expect(result?.debug.match_failure_stage).toBeDefined();
|
||||||
|
|
||||||
expect([
|
expect([
|
||||||
|
"error",
|
||||||
"no_raw_rows",
|
"no_raw_rows",
|
||||||
"raw_rows_received_but_not_materialized",
|
"raw_rows_received_but_not_materialized",
|
||||||
"materialized_but_not_anchor_matched",
|
"materialized_but_not_anchor_matched",
|
||||||
|
|
@ -122,6 +286,50 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||||
expect(result?.debug.account_scope_match_strategy).toBe("account_code_regex_plus_alias_map_v1");
|
expect(result?.debug.account_scope_match_strategy).toBe("account_code_regex_plus_alias_map_v1");
|
||||||
expect(result?.debug.account_scope_drop_reason).toBeDefined();
|
expect(result?.debug.account_scope_drop_reason).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps short slang docs request in address lane (no deep fallback)", async () => {
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle("какие доки есть по свк за 2021");
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(result?.debug.detected_mode).toBe("address_query");
|
||||||
|
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
|
||||||
|
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result?.debug.extracted_filters.period_from).toBe("2021-01-01");
|
||||||
|
expect(result?.debug.extracted_filters.period_to).toBe("2021-12-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps typo slang docs request in address lane and extracts implicit anchor", async () => {
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle("свк доки за 20год покеж");
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(result?.debug.detected_mode).toBe("address_query");
|
||||||
|
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
|
||||||
|
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01");
|
||||||
|
expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps noisy docs request in address lane and ignores slang tail token", async () => {
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle("свк 20 год - покажи доки плс");
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(result?.debug.detected_mode).toBe("address_query");
|
||||||
|
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
|
||||||
|
expect(result?.debug.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result?.debug.extracted_filters.counterparty).not.toBe("плс");
|
||||||
|
expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01");
|
||||||
|
expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-broadens out-of-window period and returns available factual rows", async () => {
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle("Какие документы по СВК за 2000 год?");
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON"]).toContain(result?.response_type);
|
||||||
|
if (result?.response_type === "FACTUAL_LIST") {
|
||||||
|
expect(result?.debug.limited_reason_category).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("address recipe catalog counterparty filtering", () => {
|
describe("address recipe catalog counterparty filtering", () => {
|
||||||
|
|
@ -134,7 +342,7 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(selected.selected_recipe).toBeTruthy();
|
expect(selected.selected_recipe).toBeTruthy();
|
||||||
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
|
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
|
||||||
|
|
||||||
expect(plan.limit).toBe(200);
|
expect(plan.limit).toBe(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("boosts limit for english all-time counterparty queries", () => {
|
it("boosts limit for english all-time counterparty queries", () => {
|
||||||
|
|
@ -146,7 +354,7 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(selected.selected_recipe).toBeTruthy();
|
expect(selected.selected_recipe).toBeTruthy();
|
||||||
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
|
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
|
||||||
|
|
||||||
expect(plan.limit).toBe(200);
|
expect(plan.limit).toBe(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cuts english all-time tail from counterparty anchor", () => {
|
it("cuts english all-time tail from counterparty anchor", () => {
|
||||||
|
|
@ -159,4 +367,16 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(result.extracted_filters.period_to).toBeUndefined();
|
expect(result.extracted_filters.period_to).toBeUndefined();
|
||||||
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("boosts limit for account snapshot queries with explicit account", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Какой остаток по счету 60 на дату 2020-07-31",
|
||||||
|
"account_balance_snapshot"
|
||||||
|
).extracted_filters;
|
||||||
|
const selected = selectAddressRecipe("account_balance_snapshot", filters);
|
||||||
|
expect(selected.selected_recipe).toBeTruthy();
|
||||||
|
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
|
||||||
|
|
||||||
|
expect(plan.limit).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AssistantService } from "../src/services/assistantService";
|
||||||
|
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||||
|
|
||||||
|
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
reply_text: "Собран список документов по контрагенту.",
|
||||||
|
reply_type: "factual",
|
||||||
|
response_type: "FACTUAL_LIST",
|
||||||
|
debug: {
|
||||||
|
detected_mode: "address_query",
|
||||||
|
detected_mode_confidence: "high",
|
||||||
|
query_shape: "DOCUMENT_LIST",
|
||||||
|
query_shape_confidence: "medium",
|
||||||
|
detected_intent: "list_documents_by_counterparty",
|
||||||
|
detected_intent_confidence: "medium",
|
||||||
|
extracted_filters: {
|
||||||
|
sort: "period_desc",
|
||||||
|
limit: 20,
|
||||||
|
counterparty: "свк"
|
||||||
|
},
|
||||||
|
missing_required_filters: [],
|
||||||
|
selected_recipe: "address_documents_by_counterparty_v1",
|
||||||
|
mcp_call_status_legacy: "matched_non_empty",
|
||||||
|
account_scope_mode: "preferred",
|
||||||
|
account_scope_fallback_applied: false,
|
||||||
|
anchor_type: "counterparty",
|
||||||
|
anchor_value_raw: "свк",
|
||||||
|
anchor_value_resolved: "Группа СВК",
|
||||||
|
resolver_confidence: "medium",
|
||||||
|
ambiguity_count: 0,
|
||||||
|
match_failure_stage: "none",
|
||||||
|
match_failure_reason: null,
|
||||||
|
mcp_call_status: "matched_non_empty",
|
||||||
|
rows_fetched: 20,
|
||||||
|
raw_rows_received: 20,
|
||||||
|
rows_after_account_scope: 5,
|
||||||
|
rows_after_recipe_filter: 3,
|
||||||
|
rows_materialized: 5,
|
||||||
|
rows_matched: 3,
|
||||||
|
raw_row_keys_sample: [],
|
||||||
|
materialization_drop_reason: "none",
|
||||||
|
account_token_raw: null,
|
||||||
|
account_token_normalized: null,
|
||||||
|
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
|
||||||
|
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
|
||||||
|
account_scope_drop_reason: "not_applicable",
|
||||||
|
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||||
|
limited_reason_category: null,
|
||||||
|
response_type: "FACTUAL_LIST",
|
||||||
|
limitations: [],
|
||||||
|
reasons: ["address_action_detected", "address_entity_detected"]
|
||||||
|
},
|
||||||
|
...(overrides ?? {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant address follow-up carryover", () => {
|
||||||
|
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
|
||||||
|
const calls: Array<{ message: string; options?: any }> = [];
|
||||||
|
const addressQueryService = {
|
||||||
|
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||||
|
calls.push({ message, options });
|
||||||
|
if (message === "какие есть доки по свк с 2020 по 2025 год") {
|
||||||
|
return buildAddressLaneResult();
|
||||||
|
}
|
||||||
|
if (message === "а за все время?" && !options?.followupContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (message === "а за все время?" && options?.followupContext) {
|
||||||
|
return buildAddressLaneResult({
|
||||||
|
reply_text: "Собран список документов по контрагенту за все время.",
|
||||||
|
debug: {
|
||||||
|
...buildAddressLaneResult().debug,
|
||||||
|
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const normalizerService = {
|
||||||
|
normalize: vi.fn(async () => ({
|
||||||
|
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||||||
|
reply_type: "partial_coverage",
|
||||||
|
debug: {}
|
||||||
|
}))
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const sessions = new AssistantSessionStore();
|
||||||
|
const service = new AssistantService(
|
||||||
|
normalizerService,
|
||||||
|
sessions as any,
|
||||||
|
{} as any,
|
||||||
|
{ persistSession: vi.fn() } as any,
|
||||||
|
addressQueryService
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionId = `asst-address-followup-${Date.now()}`;
|
||||||
|
const first = await service.handleMessage({
|
||||||
|
session_id: sessionId,
|
||||||
|
user_message: "какие есть доки по свк с 2020 по 2025 год",
|
||||||
|
useMock: true
|
||||||
|
} as any);
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
expect(first.reply_type).toBe("factual");
|
||||||
|
|
||||||
|
const second = await service.handleMessage({
|
||||||
|
session_id: sessionId,
|
||||||
|
user_message: "а за все время?",
|
||||||
|
useMock: true
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
expect(second.reply_type).toBe("factual");
|
||||||
|
expect(second.debug?.detected_mode).toBe("address_query");
|
||||||
|
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
|
||||||
|
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
|
||||||
|
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
expect(calls[0].message).toBe("какие есть доки по свк с 2020 по 2025 год");
|
||||||
|
expect(calls[1].message).toBe("а за все время?");
|
||||||
|
expect(calls[1].options?.followupContext).toBeUndefined();
|
||||||
|
expect(calls[2].message).toBe("а за все время?");
|
||||||
|
expect(calls[2].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||||
|
expect(calls[2].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||||
|
expect(calls[2].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
|
||||||
|
expect(calls[2].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
|
||||||
|
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NDC AI Normalizer Playground</title>
|
<title>NDC AI Normalizer Playground</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B5_Zqbf2.js"></script>
|
<script type="module" crossorigin src="/assets/index-BFy6DcyX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Ch7jCAii.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Ch7jCAii.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
|
||||||
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
|
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
|
||||||
const DEFAULT_UI_MODE: UiMode = "assistant";
|
const DEFAULT_UI_MODE: UiMode = "assistant";
|
||||||
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
|
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
|
||||||
|
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
|
||||||
|
|
||||||
function withTs(message: string): string {
|
function withTs(message: string): string {
|
||||||
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
|
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
|
||||||
|
|
@ -49,6 +50,8 @@ export default function App() {
|
||||||
const [appLogs, setAppLogs] = useState<string[]>([]);
|
const [appLogs, setAppLogs] = useState<string[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("normalized");
|
const [activeTab, setActiveTab] = useState<TabKey>("normalized");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [modelsBusy, setModelsBusy] = useState(false);
|
||||||
|
const [modelOptions, setModelOptions] = useState<string[]>([]);
|
||||||
const [connectionStatus, setConnectionStatus] = useState("");
|
const [connectionStatus, setConnectionStatus] = useState("");
|
||||||
const [presetList, setPresetList] = useState<
|
const [presetList, setPresetList] = useState<
|
||||||
Array<{
|
Array<{
|
||||||
|
|
@ -104,6 +107,7 @@ export default function App() {
|
||||||
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
|
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
|
||||||
setConnection((prev) => ({
|
setConnection((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
llmProvider: parsed.llmProvider === "local" ? "local" : "openai",
|
||||||
model: parsed.model ?? prev.model,
|
model: parsed.model ?? prev.model,
|
||||||
baseUrl: parsed.baseUrl ?? prev.baseUrl,
|
baseUrl: parsed.baseUrl ?? prev.baseUrl,
|
||||||
temperature: parsed.temperature ?? prev.temperature,
|
temperature: parsed.temperature ?? prev.temperature,
|
||||||
|
|
@ -174,6 +178,7 @@ export default function App() {
|
||||||
SESSION_CONFIG_KEY,
|
SESSION_CONFIG_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
model: connection.model,
|
model: connection.model,
|
||||||
|
llmProvider: connection.llmProvider,
|
||||||
baseUrl: connection.baseUrl,
|
baseUrl: connection.baseUrl,
|
||||||
temperature: connection.temperature,
|
temperature: connection.temperature,
|
||||||
maxOutputTokens: connection.maxOutputTokens
|
maxOutputTokens: connection.maxOutputTokens
|
||||||
|
|
@ -187,8 +192,24 @@ export default function App() {
|
||||||
setLastError("");
|
setLastError("");
|
||||||
try {
|
try {
|
||||||
const payload = await apiClient.testConnection(connection);
|
const payload = await apiClient.testConnection(connection);
|
||||||
setConnectionStatus(`OK - ${payload.model}`);
|
if (payload.provider === "local") {
|
||||||
|
if (payload.model_found === true) {
|
||||||
|
setConnectionStatus(`LOCAL OK - ${payload.model}`);
|
||||||
|
log(`Local model is available: ${payload.model} (catalog size=${payload.models_count ?? "n/a"}).`);
|
||||||
|
} else if (payload.model_found === false) {
|
||||||
|
setConnectionStatus(`LOCAL OK, model not loaded - ${payload.model}`);
|
||||||
|
log(
|
||||||
|
`Local server is reachable, but model '${payload.model}' is not in loaded catalog. ` +
|
||||||
|
`Use 'Load model list' and select one of loaded models.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setConnectionStatus(`LOCAL OK (model list unavailable) - ${payload.model}`);
|
||||||
|
log("Local server is reachable, but model catalog could not be verified.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setConnectionStatus(`OPENAI OK - ${payload.model}`);
|
||||||
log(`OpenAI connection ok: ${payload.model}`);
|
log(`OpenAI connection ok: ${payload.model}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setConnectionStatus("Connection error");
|
setConnectionStatus("Connection error");
|
||||||
|
|
@ -199,6 +220,33 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reloadModels() {
|
||||||
|
setModelsBusy(true);
|
||||||
|
try {
|
||||||
|
const payload = await apiClient.listModels(connection);
|
||||||
|
const models = payload.models ?? [];
|
||||||
|
setModelOptions(models);
|
||||||
|
if (models.length > 0) {
|
||||||
|
setConnection((prev) => {
|
||||||
|
if (prev.model && models.includes(prev.model)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, model: models[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
log(`Model catalog loaded (${connection.llmProvider}): ${models.length} items.`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
log(`Load model list error: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setModelsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setModelOptions([]);
|
||||||
|
}, [connection.llmProvider, connection.baseUrl]);
|
||||||
|
|
||||||
async function normalize(saveAsCase: boolean) {
|
async function normalize(saveAsCase: boolean) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setLastError("");
|
setLastError("");
|
||||||
|
|
@ -451,7 +499,7 @@ export default function App() {
|
||||||
prompts,
|
prompts,
|
||||||
userMessage,
|
userMessage,
|
||||||
sessionId: assistantSessionId || undefined,
|
sessionId: assistantSessionId || undefined,
|
||||||
promptVersion: "normalizer_v2_0_2",
|
promptVersion: ASSISTANT_PROMPT_VERSION,
|
||||||
context: {
|
context: {
|
||||||
periodHint: query.periodHint,
|
periodHint: query.periodHint,
|
||||||
businessContext: query.businessContext
|
businessContext: query.businessContext
|
||||||
|
|
@ -504,7 +552,10 @@ export default function App() {
|
||||||
<div className="layout-grid">
|
<div className="layout-grid">
|
||||||
<ConnectionPanel
|
<ConnectionPanel
|
||||||
value={connection}
|
value={connection}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
modelsBusy={modelsBusy}
|
||||||
onChange={setConnection}
|
onChange={setConnection}
|
||||||
|
onReloadModels={reloadModels}
|
||||||
onSaveLocalConfig={saveLocalConfig}
|
onSaveLocalConfig={saveLocalConfig}
|
||||||
onTestConnection={testConnection}
|
onTestConnection={testConnection}
|
||||||
lastStatus={connectionStatus}
|
lastStatus={connectionStatus}
|
||||||
|
|
@ -548,7 +599,10 @@ export default function App() {
|
||||||
<div className="layout-grid">
|
<div className="layout-grid">
|
||||||
<ConnectionPanel
|
<ConnectionPanel
|
||||||
value={connection}
|
value={connection}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
modelsBusy={modelsBusy}
|
||||||
onChange={setConnection}
|
onChange={setConnection}
|
||||||
|
onReloadModels={reloadModels}
|
||||||
onSaveLocalConfig={saveLocalConfig}
|
onSaveLocalConfig={saveLocalConfig}
|
||||||
onTestConnection={testConnection}
|
onTestConnection={testConnection}
|
||||||
lastStatus={connectionStatus}
|
lastStatus={connectionStatus}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,30 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
async testConnection(connection: ConnectionState): Promise<{ ok: boolean; model: string; timestamp: string }> {
|
async listModels(connection: ConnectionState): Promise<{ ok: boolean; models: string[]; count: number; timestamp: string }> {
|
||||||
return request("/openai/test-connection", {
|
return request("/llm/models", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
llmProvider: connection.llmProvider,
|
||||||
|
apiKey: connection.apiKey,
|
||||||
|
model: connection.model,
|
||||||
|
baseUrl: connection.baseUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async testConnection(connection: ConnectionState): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
provider: "openai" | "local";
|
||||||
|
model: string;
|
||||||
|
model_found: boolean | null;
|
||||||
|
models_count: number | null;
|
||||||
|
timestamp: string;
|
||||||
|
}> {
|
||||||
|
return request("/llm/test-connection", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
llmProvider: connection.llmProvider,
|
||||||
apiKey: connection.apiKey,
|
apiKey: connection.apiKey,
|
||||||
model: connection.model,
|
model: connection.model,
|
||||||
baseUrl: connection.baseUrl
|
baseUrl: connection.baseUrl
|
||||||
|
|
@ -54,6 +74,7 @@ export const apiClient = {
|
||||||
return request("/normalize", {
|
return request("/normalize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
llmProvider: params.connection.llmProvider,
|
||||||
apiKey: params.connection.apiKey,
|
apiKey: params.connection.apiKey,
|
||||||
model: params.connection.model,
|
model: params.connection.model,
|
||||||
baseUrl: params.connection.baseUrl,
|
baseUrl: params.connection.baseUrl,
|
||||||
|
|
@ -130,6 +151,7 @@ export const apiClient = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
normalizeConfig: {
|
normalizeConfig: {
|
||||||
|
llmProvider: input.connection.llmProvider,
|
||||||
apiKey: input.connection.apiKey,
|
apiKey: input.connection.apiKey,
|
||||||
model: input.connection.model,
|
model: input.connection.model,
|
||||||
baseUrl: input.connection.baseUrl,
|
baseUrl: input.connection.baseUrl,
|
||||||
|
|
@ -203,12 +225,13 @@ export const apiClient = {
|
||||||
mode: "assistant",
|
mode: "assistant",
|
||||||
message: input.userMessage,
|
message: input.userMessage,
|
||||||
user_message: input.userMessage,
|
user_message: input.userMessage,
|
||||||
|
llmProvider: input.connection.llmProvider,
|
||||||
apiKey: input.connection.apiKey,
|
apiKey: input.connection.apiKey,
|
||||||
model: input.connection.model,
|
model: input.connection.model,
|
||||||
baseUrl: input.connection.baseUrl,
|
baseUrl: input.connection.baseUrl,
|
||||||
temperature: input.connection.temperature,
|
temperature: input.connection.temperature,
|
||||||
maxOutputTokens: input.connection.maxOutputTokens,
|
maxOutputTokens: input.connection.maxOutputTokens,
|
||||||
promptVersion: input.promptVersion ?? "normalizer_v2_0_2",
|
promptVersion: input.promptVersion ?? "address_query_runtime_v1",
|
||||||
systemPrompt: input.prompts.systemPrompt,
|
systemPrompt: input.prompts.systemPrompt,
|
||||||
developerPrompt: input.prompts.developerPrompt,
|
developerPrompt: input.prompts.developerPrompt,
|
||||||
domainPrompt: input.prompts.domainPrompt,
|
domainPrompt: input.prompts.domainPrompt,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import type { ConnectionState } from "../state/types";
|
||||||
|
|
||||||
interface ConnectionPanelProps {
|
interface ConnectionPanelProps {
|
||||||
value: ConnectionState;
|
value: ConnectionState;
|
||||||
|
modelOptions: string[];
|
||||||
|
modelsBusy: boolean;
|
||||||
onChange: (next: ConnectionState) => void;
|
onChange: (next: ConnectionState) => void;
|
||||||
|
onReloadModels: () => Promise<void> | void;
|
||||||
onTestConnection: () => Promise<void> | void;
|
onTestConnection: () => Promise<void> | void;
|
||||||
onSaveLocalConfig: () => void;
|
onSaveLocalConfig: () => void;
|
||||||
lastStatus: string;
|
lastStatus: string;
|
||||||
|
|
@ -12,20 +15,75 @@ interface ConnectionPanelProps {
|
||||||
|
|
||||||
export function ConnectionPanel({
|
export function ConnectionPanel({
|
||||||
value,
|
value,
|
||||||
|
modelOptions,
|
||||||
|
modelsBusy,
|
||||||
onChange,
|
onChange,
|
||||||
|
onReloadModels,
|
||||||
onTestConnection,
|
onTestConnection,
|
||||||
onSaveLocalConfig,
|
onSaveLocalConfig,
|
||||||
lastStatus,
|
lastStatus,
|
||||||
busy
|
busy
|
||||||
}: ConnectionPanelProps) {
|
}: ConnectionPanelProps) {
|
||||||
|
const isLocal = value.llmProvider === "local";
|
||||||
|
const modelInCatalog = modelOptions.includes(value.model);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelFrame
|
<PanelFrame
|
||||||
title="Подключение OpenAI"
|
title="LLM Connection"
|
||||||
subtitle="Ключ живет только в памяти сессии (не пишется в localStorage)."
|
subtitle="Switch between OpenAI cloud and local OpenAI-compatible server."
|
||||||
actions={<span className="status-chip">{lastStatus || "Статус: не проверено"}</span>}
|
actions={<span className="status-chip">{lastStatus || "Status: not checked"}</span>}
|
||||||
>
|
>
|
||||||
<div className="grid-two">
|
<div className="grid-two">
|
||||||
<label>
|
<label>
|
||||||
|
Provider
|
||||||
|
<select
|
||||||
|
value={value.llmProvider}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextProvider = event.target.value === "local" ? "local" : "openai";
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
llmProvider: nextProvider,
|
||||||
|
baseUrl: nextProvider === "local" ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="openai">OpenAI (token)</option>
|
||||||
|
<option value="local">Local (LM Studio / OpenAI-compatible)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Model
|
||||||
|
<select
|
||||||
|
value={modelInCatalog ? value.model : "__manual__"}
|
||||||
|
onChange={(event) => {
|
||||||
|
const selected = event.target.value;
|
||||||
|
if (selected === "__manual__") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ ...value, model: selected });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="__manual__">Manual input</option>
|
||||||
|
{modelOptions.map((modelId) => (
|
||||||
|
<option key={modelId} value={modelId}>
|
||||||
|
{modelId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Model ID (manual)
|
||||||
|
<input
|
||||||
|
value={value.model}
|
||||||
|
onChange={(event) => onChange({ ...value, model: event.target.value })}
|
||||||
|
placeholder="qwen2.5-14b-instruct or lmstudio loaded model id"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!isLocal ? (
|
||||||
|
<label className="full-width">
|
||||||
OpenAI API Key
|
OpenAI API Key
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -34,14 +92,17 @@ export function ConnectionPanel({
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
) : null}
|
||||||
Model ID
|
|
||||||
<input value={value.model} onChange={(event) => onChange({ ...value, model: event.target.value })} />
|
<label className={isLocal ? "full-width" : undefined}>
|
||||||
</label>
|
{isLocal ? "Local server base URL" : "Base URL"}
|
||||||
<label>
|
<input
|
||||||
Base URL
|
value={value.baseUrl}
|
||||||
<input value={value.baseUrl} onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} />
|
onChange={(event) => onChange({ ...value, baseUrl: event.target.value })}
|
||||||
|
placeholder={isLocal ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Temperature
|
Temperature
|
||||||
<input
|
<input
|
||||||
|
|
@ -51,6 +112,7 @@ export function ConnectionPanel({
|
||||||
onChange={(event) => onChange({ ...value, temperature: Number(event.target.value) })}
|
onChange={(event) => onChange({ ...value, temperature: Number(event.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Max output tokens
|
Max output tokens
|
||||||
<input
|
<input
|
||||||
|
|
@ -60,12 +122,16 @@ export function ConnectionPanel({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button type="button" onClick={() => onSaveLocalConfig()}>
|
<button type="button" onClick={() => onSaveLocalConfig()}>
|
||||||
Сохранить локальную конфигурацию
|
Save local config
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onReloadModels()} disabled={busy || modelsBusy}>
|
||||||
|
{modelsBusy ? "Loading models..." : "Load model list"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => onTestConnection()} disabled={busy}>
|
<button type="button" onClick={() => onTestConnection()} disabled={busy}>
|
||||||
{busy ? "Проверяем..." : "Проверить подключение"}
|
{busy ? "Checking..." : "Test connection"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</PanelFrame>
|
</PanelFrame>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ConnectionState, PromptState, QueryState } from "./types";
|
import type { ConnectionState, PromptState, QueryState } from "./types";
|
||||||
|
|
||||||
export const DEFAULT_CONNECTION: ConnectionState = {
|
export const DEFAULT_CONNECTION: ConnectionState = {
|
||||||
|
llmProvider: "openai",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
model: "gpt-4o-mini",
|
model: "gpt-4o-mini",
|
||||||
baseUrl: "https://api.openai.com/v1",
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export type TabKey = "normalized" | "fragments" | "scope" | "flags" | "route" | "raw" | "validation" | "logs";
|
export type TabKey = "normalized" | "fragments" | "scope" | "flags" | "route" | "raw" | "validation" | "logs";
|
||||||
|
|
||||||
export interface ConnectionState {
|
export interface ConnectionState {
|
||||||
|
llmProvider: "openai" | "local";
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue