diff --git a/backend/src/api/routes/translate/_preview_routes.py b/backend/src/api/routes/translate/_preview_routes.py index 2dfe776b..cf054cfe 100644 --- a/backend/src/api/routes/translate/_preview_routes.py +++ b/backend/src/api/routes/translate/_preview_routes.py @@ -51,6 +51,7 @@ async def preview_translation( ) return result except ValueError as e: + logger.explore("preview_translation validation failed", extra={"src": "translate_routes", "error": str(e)}) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: logger.explore("preview_translation failed", extra={"src": "translate_routes", "error": str(e)}) diff --git a/backend/src/plugins/translate/executor.py b/backend/src/plugins/translate/executor.py index a5ccebe6..4cd434dc 100644 --- a/backend/src/plugins/translate/executor.py +++ b/backend/src/plugins/translate/executor.py @@ -999,8 +999,10 @@ class TranslationExecutor: try: data = json.loads(match.group(1)) except json.JSONDecodeError: + logger.explore("LLM response was not valid JSON (after markdown extraction)", extra={"src": "executor", "response_preview": response_text[:1000]}) raise ValueError("LLM response was not valid JSON") else: + logger.explore("LLM response was not valid JSON (no markdown block)", extra={"src": "executor", "response_preview": response_text[:1000]}) raise ValueError("LLM response was not valid JSON") rows = data.get("rows", []) diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index 62c6319c..21205ddf 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -1005,8 +1005,10 @@ class TranslationPreview: try: data = json.loads(match.group(1)) except json.JSONDecodeError: + logger.explore("LLM response was not valid JSON (after markdown extraction)", extra={"src": "preview", "response_preview": response_text[:1000]}) raise ValueError("LLM response was not valid JSON") else: + logger.explore("LLM response was not valid JSON (no markdown block)", extra={"src": "preview", "response_preview": response_text[:1000]}) raise ValueError("LLM response was not valid JSON") rows = data.get("rows", []) diff --git a/scripts/generate_financial_arrears_data.py b/scripts/generate_financial_arrears_data.py new file mode 100755 index 00000000..750f549d --- /dev/null +++ b/scripts/generate_financial_arrears_data.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Generate test data for dm_view.financial_arrears on ClickHouse (ss-dev). + +Inserts 5000 rows with comment_text in Russian (80%), Chinese (10%), English (10%), +200-1000 chars each, using financial/judicial terminology related to RUSAL overdue debt. + +Usage: + cd backend && source .venv/bin/activate + python ../scripts/generate_financial_arrears_data.py +""" +import os +import random +import json +from datetime import date, datetime, timedelta + +BACKEND_DIR = os.path.join(os.path.dirname(__file__), "..", "backend") +os.chdir(BACKEND_DIR) + +from src.core.config_manager import ConfigManager +from src.plugins.translate.superset_executor import SupersetSqlLabExecutor + +# ── Seed for reproducibility ──────────────────────────────── +RANDOM_SEED = 42 +random.seed(RANDOM_SEED) + +# ── Counterparties (контрагенты РУСАЛа) ───────────────────── +COUNTERPARTIES = [ + 'АО "РУСАЛ Красноярск"', + 'АО "РУСАЛ Братск"', + 'АО "РУСАЛ Саяногорск"', + 'АО "РУСАЛ Новокузнецк"', + 'ООО "РУСАЛ Торговый Дом"', + 'ООО "РУСАЛ Логистика"', + 'АО "РУСАЛ Урал"', + 'ООО "Инженерно-технологический центр РУСАЛ"', + 'ООО "РУСАЛ Глобал Менеджмент"', + 'АО "Боксит Тимана"', + 'ООО "РУС-Инжиниринг"', + 'ООО "Торговый дом Алюминий"', + 'АО "Сибирско-Уральская алюминиевая компания"', + 'ООО "Алюминиевые технологии"', + 'ООО "Металлургический завод"', + 'ПАО "Корпорация ВСМПО-АВИСМА"', + 'ООО "Транспортная компания"', + 'ООО "Энергосбыт"', + 'АО "РУСАЛ Ачинск"', + 'АО "РУСАЛ Волгоград"', +] + +SUPPLY_COUNTRIES = [ + "Россия", "Россия", "Россия", "Россия", "Россия", + "Россия", "Россия", "Россия", + "Китай", "Китай", + "Казахстан", "Гвинея", "Австралия", "Бразилия", + "Индия", "Индонезия", "ОАЭ", "Ямайка", +] + +# ── Russian financial/judicial comment templates ───────────── +RUSSIAN_COMMENT_PARTS = { + "openers": [ + "В отношении задолженности", + "По состоянию на отчётную дату", + "Согласно акту сверки взаимных расчётов", + "В соответствии с решением арбитражного суда", + "На основании исполнительного листа", + "По договору поставки", + "В рамках дела о банкротстве", + "Согласно претензионному порядку", + "По данным бухгалтерского учёта", + "В связи с неисполнением обязательств", + "По результатам инвентаризации дебиторской задолженности", + "Согласно определению Арбитражного суда Красноярского края", + "В соответствии с Федеральным законом № 127-ФЗ", + "На основании решения налогового органа", + "По товарной накладной", + ], + "debtors": [ + "предприятия РУСАЛ", + "дочернего общества РУСАЛ", + "алюминиевого завода", + "обогатительной фабрики", + "транспортного цеха", + "энергосбытовой компании", + "торгового дома", + "литейного производства", + "глинозёмного комбината", + "рудодобывающего предприятия", + "ремонтно-механического завода", + "строительно-монтажного управления", + ], + "amounts": [ + "на сумму 15 780 432,50 руб.", + "в размере 42 156 789,00 руб.", + "на общую сумму 8 934 561,23 руб.", + "в сумме 231 567 890,00 руб.", + "на 5 678 912,34 руб.", + "в размере 89 345 678,00 руб.", + "на сумму 1 234 567,89 руб.", + "в объёме 67 890 123,45 руб.", + "на сумму 345 678 901,20 руб.", + "в размере 12 456 789,00 руб.", + "на общую сумму 54 321 098,76 руб.", + "в сумме 987 654 321,00 руб.", + ], + "terms": [ + "со сроком просрочки свыше 180 календарных дней", + "просрочка исполнения составляет более 120 дней", + "с нарушением срока оплаты свыше 90 дней", + "период просрочки превышает 150 дней", + "с просрочкой платежа более 210 дней", + "нарушение условий договора по срокам оплаты свыше 60 дней", + "срок неисполнения обязательств превышает 270 дней", + "период задержки платежа составляет более 300 дней", + "с просрочкой исполнения денежного обязательства свыше 365 дней", + ], + "legal_actions": [ + "направлена досудебная претензия", + "подан иск в арбитражный суд", + "вынесено решение о взыскании задолженности", + "возбуждено исполнительное производство", + "наложен арест на расчётные счета", + "подано заявление о признании банкротом", + "заключено мировое соглашение", + "утверждён график реструктуризации долга", + "приостановлено исполнение обязательств", + "введена процедура наблюдения", + "открыто конкурсное производство", + "обжаловано в апелляционной инстанции", + "подана кассационная жалоба", + "выдан исполнительный лист", + "направлено требование о погашении", + "инициирована процедура медиации", + "проведена сверка задолженности", + ], + "classifications": [ + "Задолженность признана безнадёжной к взысканию", + "Дебиторская задолженность классифицирована как сомнительная", + "Отнесена к категории проблемных активов", + "Признана нереальной к взысканию в связи с ликвидацией должника", + "Классифицирована как просроченная свыше 3 лет", + "Отнесена на формирование резерва по сомнительным долгам", + "Признана маловероятной к взысканию в досудебном порядке", + "Классифицирована как высокорискованная дебиторская задолженность", + "Включена в реестр требований кредиторов", + "Признана текущей задолженностью в процедуре банкротства", + ], + "reserve_mentions": [ + "Сформирован резерв по сомнительным долгам", + "Резерв начислен в размере 100% от суммы задолженности", + "Оценочный резерв составляет 50% от номинальной суммы", + "Резервирование произведено на основании МСФО (IAS) 39", + "Создан оценочный резерв под обесценение дебиторской задолженности", + "Начислен резерв по сомнительной задолженности согласно РСБУ", + "Формирование резерва приостановлено до решения суда", + "Корректировка резерва произведена в отчётном периоде", + ], + "closers": [ + "Требуется дополнительный анализ платёжеспособности", + "Проведён анализ финансового состояния должника", + "Рекомендовано списание за счёт чистой прибыли", + "Направлено уведомление о расторжении договора", + "Ожидается решение вышестоящего суда", + "Рассмотрение дела отложено на неопределённый срок", + "Проводится мониторинг финансового состояния контрагента", + "Инициирована внесудебная процедура урегулирования", + "Подготовлено заключение о целесообразности взыскания", + "Рекомендовано обратиться в службу судебных приставов", + "Требуется продление срока исковой давности", + "Осуществляется контроль за ходом исполнительного производства", + ], +} + +# ── Chinese financial/judicial terms ───────────────────────── +CHINESE_COMMENT_EXAMPLES = [ + "关于俄罗斯铝业集团子公司的逾期应收账款,金额为45,678,901.23卢布,逾期天数已超过180天。根据仲裁法院判决,已启动强制执行程序,冻结了债务人的银行账户。" + "同时,正在准备破产申请材料。此笔债务已被分类为可疑应收账款,并计提了50%的减值准备。正在进行债务人偿付能力的全面分析。", + "根据相互结算对账报告,截至报告日,未履行的供货合同义务金额为12,345,678.90卢布。已发出预审索赔通知,要求债务人在30日内清偿债务。" + "若逾期未能偿还,将向阿尔比特拉日法院提起诉讼,并申请财产保全措施。本笔应收账款被列为高风险类别。", + "依据联邦破产法第127号的规定,已向仲裁法院提交债务人破产申请。债务总额为234,567,890.00卢布,逾期超过270天。法院已启动监督程序," + "并任命了临时管理人。债权人会议定于下月召开,届时将审议债务重组方案。应收账款准备按100%计提。", + "关于俄罗斯铝业联合公司子公司的逾期应付账款,金额为89,012,345.67卢布。根据法院判决,已发出执行令状并提交法警执行。初步分析表明," + "债务人存在明显的偿付能力不足迹象。建议对该笔应收款进行全额减值处理,并寻求额外的担保措施。同时,正在审查合同条款中的违约责任条款。", + "根据库存应收账款盘点结果,已识别出逾期超过365天的可疑债务,金额为567,890,123.45卢布。债务人为俄罗斯铝业集团的前供应商,目前正在" + "进行清算程序。债权已列入债权人名册。预计回收率不超过5%。建议管理层批准将该笔应收账款核销。", + "在破产案件的框架下,已完成对债务人俄罗斯铝业子公司的财务分析。应收账款总额为78,901,234.56卢布。法院已裁定实行外部管理," + "并暂停了所有单独的债权执行程序。债权人委员会已批准了债务重组计划,期限为18个月。目前正在监控重组计划的执行情况。", + "仲裁法院克拉斯诺亚尔斯克边疆区分院已作出判决,全额支持我方索赔请求,金额为34,567,890.12卢布。债务人提起了上诉," + "案件已移交至上诉法院审理。同时,已申请采取临时措施,包括冻结债务人的不动产和银行账户。预计下一场听证会在两个月内举行。", + "关于逾期应收账款的追偿,已向债务人发出正式追偿要求。债务总额为56,789,012.34卢布,逾期超过90天。债务人提出了分期偿还的方案," + "建议在6个月内分6期偿还。我方正在审查该方案,并评估是否需要提供额外的担保。同时,正在计算逾期利息和违约金。", +] + +# ── English financial/judicial terms ───────────────────────── +ENGLISH_COMMENT_EXAMPLES = [ + "With respect to the overdue accounts receivable of RUSAL subsidiary in the amount of RUB 67,890,123.45, the delinquency period exceeds 210 days. " + "A statement of claim has been filed with the Arbitration Court of the Krasnoyarsk Territory. The court has granted interim measures, including " + "the seizure of the debtor's bank accounts. An impairment provision of 100% has been recognized in accordance with IFRS 9. The expected credit " + "loss (ECL) model indicates a probability of default exceeding 95% over a 12-month horizon.", + "Pursuant to the reconciliation statement, the outstanding amount of RUB 23,456,789.01 under the supply agreement has been overdue for more than " + "150 days. A pre-trial claim was dispatched on 15 March 2024. The debtor has requested a moratorium on payments citing liquidity constraints. " + "The receivable has been classified as Stage 3 under IFRS 9 (credit-impaired). A restructuring proposal is under evaluation.", + "In accordance with Federal Law No. 127-FZ, a petition for declaring the debtor bankrupt has been submitted to the Arbitration Court. The total " + "claim amount is RUB 890,123,456.78. The court has commenced supervision proceedings. The temporary manager has been appointed and the first " + "creditors' meeting is scheduled for next quarter. As per IAS 36, an impairment test has been performed, resulting in a full write-down.", + "Based on the inventory of accounts receivable, an overdue amount of RUB 12,345,678.90 has been identified as uncollectible due to the debtor's " + "liquidation. The creditor claim has been included in the register of creditors' claims. Management has approved the write-off against retained " + "earnings. Tax implications of this write-off are being assessed in accordance with the Russian Tax Code Article 265.", + "The enforcement proceedings have been initiated based on the writ of execution issued by the Arbitration Court. The bailiff service has imposed " + "a garnishment on the debtor's accounts at multiple banks. The outstanding principal amount is RUB 45,678,901.23, plus accrued interest of " + "RUB 5,432,109.87 calculated at the contractual rate of 0.1% per day of delay.", +] + +# ── Helpers ────────────────────────────────────────────────── + + +def _random_date(start: date, end: date) -> date: + delta = (end - start).days + return start + timedelta(days=random.randint(0, delta)) + + +def _doc_number(idx: int) -> str: + prefixes = ["ДЗ", "ИНВ", "АКТ", "ПРМ", "СЧ", "НКЛ", "ИСК", "РШН"] + prefix = random.choice(prefixes) + return f"{prefix}-{idx:06d}" + + +def _build_russian_comment() -> str: + parts = [ + random.choice(RUSSIAN_COMMENT_PARTS["openers"]), + random.choice(RUSSIAN_COMMENT_PARTS["debtors"]), + random.choice(RUSSIAN_COMMENT_PARTS["amounts"]), + random.choice(RUSSIAN_COMMENT_PARTS["terms"]), + ] + + n_legal = random.randint(2, 4) + legal = random.sample(RUSSIAN_COMMENT_PARTS["legal_actions"], n_legal) + parts.append(", ".join(legal)) + parts.append(random.choice(RUSSIAN_COMMENT_PARTS["classifications"])) + + if random.random() < 0.7: + parts.append(random.choice(RUSSIAN_COMMENT_PARTS["reserve_mentions"])) + parts.append(random.choice(RUSSIAN_COMMENT_PARTS["closers"])) + + if random.random() < 0.5: + extra = [ + f"Дата возникновения задолженности: " + f"{_random_date(date(2022, 1, 1), date(2024, 6, 30)).strftime('%d.%m.%Y')}.", + f"Реквизиты договора: № {random.randint(100, 999)}/{random.randint(2020, 2024)}-АЛ.", + f"Ответственный менеджер: " + f"{random.choice(['Иванов И.И.', 'Петров П.П.', 'Сидоров С.С.', 'Кузнецов А.В.', 'Смирнова Е.А.'])}.", + f"Номер дела в суде: А{random.randint(10, 99)}-{random.randint(10000, 99999)}/" + f"{random.randint(2023, 2024)}.", + f"Статья закона: ст. {random.choice(['309', '310', '330', '395', '450'])} ГК РФ.", + f"Ставка пени: {random.choice(['0.05%', '0.1%', '0.15%', '0.2%'])} за каждый день просрочки.", + f"Дата последнего платежа: " + f"{_random_date(date(2023, 1, 1), date(2024, 6, 30)).strftime('%d.%m.%Y')}.", + f"Сумма основного долга: {random.randint(100000, 50000000):,} руб., " + f"пени: {random.randint(10000, 5000000):,} руб.".replace(",", " "), + ] + parts.append(random.choice(extra)) + + return ". ".join(parts) + + +def _build_chinese_comment() -> str: + return random.choice(CHINESE_COMMENT_EXAMPLES) + + +def _build_english_comment() -> str: + return random.choice(ENGLISH_COMMENT_EXAMPLES) + + +def _ensure_length(text: str, lang_rand: float) -> str: + while len(text) < 200: + if lang_rand < 0.80: + text += ". " + random.choice(RUSSIAN_COMMENT_PARTS["closers"]) + elif lang_rand < 0.90: + text += "。债务人的财务状况正在进一步分析中。" + else: + text += " The debtor's financial condition is under further review." + if len(text) > 1000: + text = text[:997] + "..." + return text + + +def generate_row(idx: int) -> dict: + lang_rand = random.random() + if lang_rand < 0.80: + comment = _build_russian_comment() + elif lang_rand < 0.90: + comment = _build_chinese_comment() + else: + comment = _build_english_comment() + + comment = _ensure_length(comment, lang_rand) + + return { + "report_date": _random_date( + date(2022, 1, 1), date(2025, 5, 15) + ).strftime("%Y-%m-%d"), + "document_number": _doc_number(idx), + "comment_text": comment, + "counterparty": random.choice(COUNTERPARTIES), + "supply_country": random.choice(SUPPLY_COUNTRIES), + } + + +# ── Main ───────────────────────────────────────────────────── + +BATCH_SIZE = 100 +TOTAL_ROWS = 5000 + + +def main(): + cm = ConfigManager() + executor = SupersetSqlLabExecutor(cm, "ss-dev") + db_id = executor.resolve_database_id( + database_name="ClickHouse Connect (Superset)" + ) + print(f"ClickHouse DB ID: {db_id}") + + print(f"Generating {TOTAL_ROWS} rows...") + all_rows = [generate_row(i + 1) for i in range(TOTAL_ROWS)] + + lengths = [len(r["comment_text"]) for r in all_rows] + print(f"Comment length range: {min(lengths)}–{max(lengths)} chars") + + ru = sum(1 for r in all_rows if any( + c in r["comment_text"] for c in "абвгдеёжзийклмнопрстуфхцчшщъыьэюя")) + cn = sum(1 for r in all_rows if any( + c in r["comment_text"] for c in "的一是不了人我在有他这")) + en = TOTAL_ROWS - ru - cn + print(f"Language: RU={ru} ({ru/TOTAL_ROWS*100:.0f}%), " + f"CN={cn} ({cn/TOTAL_ROWS*100:.0f}%), EN={en} ({en/TOTAL_ROWS*100:.0f}%)") + + total_inserted = 0 + for batch_start in range(0, TOTAL_ROWS, BATCH_SIZE): + batch = all_rows[batch_start:batch_start + BATCH_SIZE] + + values_lines = [] + for row in batch: + report_date = row["report_date"] + doc_num = row["document_number"].replace("'", "''") + comment = row["comment_text"].replace("'", "''") + counterparty = row["counterparty"].replace("'", "''") + country = row["supply_country"].replace("'", "''") + values_lines.append( + f"('{report_date}', '{doc_num}', '{comment}', " + f"'{counterparty}', '{country}')" + ) + + sql = ( + "INSERT INTO dm_view.financial_arrears " + "(`report_date`, `document_number`, `comment_text`, " + "`counterparty`, `supply_country`) VALUES\n" + + ",\n".join(values_lines) + + ";" + ) + + print( + f" Batch [{batch_start + 1}–{batch_start + len(batch)}]: " + f"SQL={len(sql):,} chars" + ) + + try: + result = executor.execute_sql( + sql, database_id=db_id, run_async=False + ) + status = result.get("status", "unknown") + if status == "success": + total_inserted += len(batch) + print(f" -> OK. Cumulative: {total_inserted}/{TOTAL_ROWS}") + else: + err = ( + result.get("raw_response", {}) + .get("query", {}) + .get("errorMessage", "unknown") + ) + print(f" -> FAIL: {err}") + except Exception as e: + print(f" -> EXCEPTION: {e}") + + print(f"\nInserted: {total_inserted}/{TOTAL_ROWS}") + + # Verify + sql_check = "SELECT count(*) as cnt FROM dm_view.financial_arrears" + res = executor.execute_sql(sql_check, database_id=db_id, run_async=False) + data = res.get("raw_response", {}).get("data", []) + print(f"Final count: {data[0]['cnt'] if data else '?'}") + + # Sample + sql_sample = ( + "SELECT * FROM dm_view.financial_arrears " + "ORDER BY report_date DESC LIMIT 3" + ) + res = executor.execute_sql(sql_sample, database_id=db_id, run_async=False) + for row in res.get("raw_response", {}).get("data", []): + print( + f" {row['report_date']} {row['document_number']} " + f"{row['counterparty'][:25]:25s} " + f"{row['comment_text'][:70]}..." + ) + + +if __name__ == "__main__": + main()