lambda-erp 0.8.31__tar.gz → 0.8.33__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/chat.py +202 -62
  3. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/pyproject.toml +1 -1
  4. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/.gitignore +0 -0
  5. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/LICENSE +0 -0
  6. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/README.md +0 -0
  7. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/__init__.py +0 -0
  8. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/attachments.py +0 -0
  9. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/auth.py +0 -0
  10. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/bootstrap.py +0 -0
  11. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/demo_limits.py +0 -0
  12. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/deps.py +0 -0
  13. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/errors.py +0 -0
  14. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/list_values.py +0 -0
  15. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/main.py +0 -0
  16. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/oauth.py +0 -0
  17. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/pdf.py +0 -0
  18. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/providers.py +0 -0
  19. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/remarks_md.py +0 -0
  20. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/__init__.py +0 -0
  21. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/accounting.py +0 -0
  22. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/actions.py +0 -0
  23. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/availability.py +0 -0
  26. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/bank_reconciliation.py +0 -0
  27. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/bank_statements.py +0 -0
  28. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/chat_api.py +0 -0
  29. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/documents.py +0 -0
  30. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/masters.py +0 -0
  31. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/mcp.py +0 -0
  32. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/proposals.py +0 -0
  33. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/reports.py +0 -0
  34. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/routers/setup.py +0 -0
  35. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/services.py +0 -0
  36. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/templates/document.html +0 -0
  37. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/templates/proposal.html +0 -0
  38. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/api/tool_permissions.py +0 -0
  39. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/docs/agents/README.md +0 -0
  40. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/frontend/LICENSE +0 -0
  41. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/frontend/README.md +0 -0
  42. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/frontend/src/api/client.ts +0 -0
  43. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/__init__.py +0 -0
  44. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/__init__.py +0 -0
  45. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/bank_account.py +0 -0
  46. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  47. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/bank_statement_import.py +0 -0
  48. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/bank_transaction.py +0 -0
  49. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/budget.py +0 -0
  50. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/camt.py +0 -0
  51. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  52. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/general_ledger.py +0 -0
  53. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/journal_entry.py +0 -0
  54. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/payment_entry.py +0 -0
  55. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/pos_invoice.py +0 -0
  56. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/purchase_invoice.py +0 -0
  57. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/revaluation.py +0 -0
  58. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/sales_invoice.py +0 -0
  59. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/__init__.py +0 -0
  60. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/engine.py +0 -0
  61. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/pack.py +0 -0
  62. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  63. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  64. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  65. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  66. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  67. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  68. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/profiles.py +0 -0
  69. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/setup/spine.py +0 -0
  70. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/accounting/subscription.py +0 -0
  71. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/assets/__init__.py +0 -0
  72. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/assets/asset.py +0 -0
  73. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/assets/reservation.py +0 -0
  74. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/buying/__init__.py +0 -0
  75. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/buying/purchase_order.py +0 -0
  76. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/controllers/__init__.py +0 -0
  77. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/controllers/currency.py +0 -0
  78. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/controllers/defaults.py +0 -0
  79. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/controllers/pricing_rule.py +0 -0
  80. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  81. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/database.py +0 -0
  82. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/exceptions.py +0 -0
  83. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/hooks.py +0 -0
  84. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/model.py +0 -0
  85. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/selling/__init__.py +0 -0
  86. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/selling/proposal.py +0 -0
  87. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/selling/quotation.py +0 -0
  88. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/selling/sales_order.py +0 -0
  89. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/simulation.py +0 -0
  90. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/stock/__init__.py +0 -0
  91. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/stock/delivery_note.py +0 -0
  92. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/stock/purchase_receipt.py +0 -0
  93. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/stock/stock_entry.py +0 -0
  94. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/stock/stock_ledger.py +0 -0
  95. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/lambda_erp/utils.py +0 -0
  96. {lambda_erp-0.8.31 → lambda_erp-0.8.33}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.31
3
+ Version: 0.8.33
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -2138,7 +2138,11 @@ def _handle_create_custom_analytics_report(args, user_info: dict | None = None,
2138
2138
 
2139
2139
  if not args.get("title"):
2140
2140
  return {"error": "title is required"}
2141
- return create_report_draft_record(args, user_info, source_chat_session_id=session_id)
2141
+ # ``intent`` is a tool-control field for the specialist handoff, not part
2142
+ # of the persisted declarative report. Keep every other key so the strict
2143
+ # ReportDraftPayload boundary still rejects unexpected/executable fields.
2144
+ payload = {key: value for key, value in args.items() if key != "intent"}
2145
+ return create_report_draft_record(payload, user_info, source_chat_session_id=session_id)
2142
2146
 
2143
2147
 
2144
2148
  def _handle_get_custom_analytics_report(args, user_info: dict | None = None):
@@ -2880,6 +2884,118 @@ def _extract_json_object(text: str) -> dict:
2880
2884
  return json.loads(candidate)
2881
2885
 
2882
2886
 
2887
+ def _report_validation_summary(exc: Exception, *, max_errors: int = 4) -> str:
2888
+ """Return useful schema paths without logging report contents.
2889
+
2890
+ Pydantic's full error string embeds rejected input values. Report intents
2891
+ can contain customer/account labels, so provider diagnostics deliberately
2892
+ retain only the field path, error type, and message.
2893
+ """
2894
+ errors = getattr(exc, "errors", None)
2895
+ if not callable(errors):
2896
+ return f"{type(exc).__name__}: {exc}"
2897
+ error_items = errors()
2898
+ parts: list[str] = []
2899
+ for error in error_items[:max_errors]:
2900
+ location = ".".join(str(part) for part in error.get("loc", ())) or "report"
2901
+ error_type = error.get("type") or "validation_error"
2902
+ message = error.get("msg") or "Invalid value"
2903
+ parts.append(f"{location} [{error_type}]: {message}")
2904
+ remaining = len(error_items) - len(parts)
2905
+ if remaining > 0:
2906
+ parts.append(f"+{remaining} more")
2907
+ return "; ".join(parts) or type(exc).__name__
2908
+
2909
+
2910
+ def _report_validation_log_summary(exc: Exception, *, max_errors: int = 4) -> str:
2911
+ """Return paths/types only, keeping model-generated labels out of logs."""
2912
+ errors = getattr(exc, "errors", None)
2913
+ if not callable(errors):
2914
+ return type(exc).__name__
2915
+ error_items = errors()
2916
+ parts = []
2917
+ for error in error_items[:max_errors]:
2918
+ location = ".".join(str(part) for part in error.get("loc", ())) or "report"
2919
+ error_type = error.get("type") or "validation_error"
2920
+ parts.append(f"{location}[{error_type}]")
2921
+ remaining = len(error_items) - len(parts)
2922
+ if remaining > 0:
2923
+ parts.append(f"+{remaining}_more")
2924
+ return ",".join(parts) or type(exc).__name__
2925
+
2926
+
2927
+ def _strip_report_model_extras(value, annotation):
2928
+ """Copy a report value through its declared Pydantic model shape.
2929
+
2930
+ Dictionaries used as actual values (notably RuntimeDataRequest.filters)
2931
+ stay untouched. Only dictionaries representing report models are narrowed
2932
+ to declared fields, recursively through nested model lists/optionals. This
2933
+ avoids depending on the per-validation ``extra=`` override added by newer
2934
+ Pydantic releases; Lambda ERP still supports Pydantic 2.x generally.
2935
+ """
2936
+ from typing import get_args, get_origin
2937
+ from pydantic import BaseModel
2938
+
2939
+ def is_model(candidate) -> bool:
2940
+ return isinstance(candidate, type) and issubclass(candidate, BaseModel)
2941
+
2942
+ if is_model(annotation):
2943
+ if not isinstance(value, dict):
2944
+ return value
2945
+ return {
2946
+ name: _strip_report_model_extras(value[name], field.annotation)
2947
+ for name, field in annotation.model_fields.items()
2948
+ if name in value
2949
+ }
2950
+
2951
+ origin = get_origin(annotation)
2952
+ args = get_args(annotation)
2953
+ if origin is list and args and isinstance(value, list):
2954
+ return [_strip_report_model_extras(item, args[0]) for item in value]
2955
+
2956
+ # The report models only use unions for optional fields. Follow the model
2957
+ # or model-list branch when one exists; primitive unions need no traversal.
2958
+ for candidate in args:
2959
+ candidate_origin = get_origin(candidate)
2960
+ if is_model(candidate) or candidate_origin is list:
2961
+ return _strip_report_model_extras(value, candidate)
2962
+ return value
2963
+
2964
+
2965
+ def _validate_report_specialist_spec(spec: dict, *, response_id: str) -> dict:
2966
+ """Normalize harmless model-only fields, then enforce the canonical model.
2967
+
2968
+ ReportDraftPayload remains strict for every stored/browser-facing report.
2969
+ The lenient pass exists only at this LLM boundary: it removes invented
2970
+ presentation hints such as ``stacked`` or ``color``. Serializing that model
2971
+ produces a canonical object which is immediately validated strictly again,
2972
+ so ignored input can never reach persistence or the browser.
2973
+ """
2974
+ from pydantic import ValidationError
2975
+ from api.routers.analytics import ReportDraftPayload
2976
+
2977
+ try:
2978
+ return ReportDraftPayload.model_validate(spec).model_dump()
2979
+ except ValidationError as strict_error:
2980
+ extra_paths = [
2981
+ ".".join(str(part) for part in error.get("loc", ())) or "report"
2982
+ for error in strict_error.errors()
2983
+ if error.get("type") == "extra_forbidden"
2984
+ ]
2985
+ if not extra_paths:
2986
+ raise
2987
+
2988
+ stripped = _strip_report_model_extras(spec, ReportDraftPayload)
2989
+ normalized = ReportDraftPayload.model_validate(stripped).model_dump()
2990
+ canonical = ReportDraftPayload.model_validate(normalized).model_dump()
2991
+ print(
2992
+ "[report_specialist] "
2993
+ f"response_id={response_id} normalized_extra_fields={','.join(extra_paths)}",
2994
+ flush=True,
2995
+ )
2996
+ return canonical
2997
+
2998
+
2883
2999
  def _generate_report_spec_via_openai(
2884
3000
  intent: str,
2885
3001
  existing_spec: dict | None = None,
@@ -2920,72 +3036,96 @@ def _generate_report_spec_via_openai(
2920
3036
  )
2921
3037
  user_msg = "\n\n".join(user_parts)
2922
3038
 
2923
- print(
2924
- f"[chat_llm] provider=openai role=report_specialist model={model}",
2925
- flush=True,
2926
- )
2927
3039
  client = OpenAI(
2928
3040
  api_key=api_key,
2929
3041
  timeout=httpx.Timeout(120.0, connect=10.0),
2930
3042
  )
2931
- reservation_id = None
2932
- if is_demo_role(user_role):
2933
- _blocked, reservation_id = demo_limiter.reserve(
2934
- client_ip or "unknown",
2935
- estimated_usd=demo_call_reserve_usd(),
2936
- role=user_role,
2937
- )
2938
- if _blocked:
2939
- raise RuntimeError(_blocked)
2940
-
2941
- # try/finally + `settled` flag guarantees reservation release even if
2942
- # a CancelledError slips between the SDK call returning and settle()
2943
- # completing. Without this, a cancelled coroutine could leak the
2944
- # reservation for the process lifetime (or until TTL sweep).
2945
- settled = False
2946
- try:
2947
- response = client.responses.create(
2948
- model=model,
2949
- instructions=_REPORT_CODE_SYSTEM_PROMPT,
2950
- input=user_msg,
2951
- max_output_tokens=4096,
2952
- reasoning={"effort": "low"},
2953
- )
2954
- # Log every call for the admin dashboard. Only public_manager rows
2955
- # count against the demo cap — other roles are logged for
2956
- # visibility but exempt from rate limiting.
2957
- usage = getattr(response, "usage", None)
2958
- demo_limiter.settle(
2959
- reservation_id,
2960
- actual_cost_usd=cost_of_openai_call(model, usage),
2961
- ip=client_ip or "unknown",
2962
- role=user_role,
2963
- provider="openai",
2964
- model=model,
2965
- prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
2966
- completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
2967
- session_id=session_id,
2968
- )
2969
- settled = True
2970
- finally:
2971
- if not settled:
2972
- demo_limiter.release(reservation_id)
2973
-
2974
- text = getattr(response, "output_text", "") or ""
2975
- if not text:
2976
- text = "".join(
2977
- getattr(block, "text", "") or ""
2978
- for item in (getattr(response, "output", None) or [])
2979
- for block in (getattr(item, "content", None) or [])
2980
- if getattr(block, "type", "") in ("output_text", "text")
3043
+ validation_feedback: str | None = None
3044
+ max_attempts = 2
3045
+
3046
+ for attempt in range(1, max_attempts + 1):
3047
+ attempt_input = user_msg
3048
+ if validation_feedback:
3049
+ attempt_input += (
3050
+ "\n\n## Validation errors from the previous attempt\n"
3051
+ + validation_feedback
3052
+ + "\nRegenerate the complete report spec and correct these errors."
3053
+ )
3054
+
3055
+ print(
3056
+ f"[chat_llm] provider=openai role=report_specialist model={model} attempt={attempt}",
3057
+ flush=True,
2981
3058
  )
2982
- spec = _extract_json_object(text)
2983
- if not spec.get("report") or not spec.get("data_requests"):
2984
- raise RuntimeError("Report specialist returned an incomplete spec.")
2985
- # Validate the complete declarative language before it can be persisted or
2986
- # sent to a browser. This also rejects legacy executable transform strings.
2987
- from api.routers.analytics import ReportDraftPayload
2988
- return ReportDraftPayload.model_validate(spec).model_dump()
3059
+ reservation_id = None
3060
+ if is_demo_role(user_role):
3061
+ _blocked, reservation_id = demo_limiter.reserve(
3062
+ client_ip or "unknown",
3063
+ estimated_usd=demo_call_reserve_usd(),
3064
+ role=user_role,
3065
+ )
3066
+ if _blocked:
3067
+ raise RuntimeError(_blocked)
3068
+
3069
+ # try/finally + `settled` flag guarantees reservation release even if
3070
+ # a CancelledError slips between the SDK call returning and settle()
3071
+ # completing. Without this, a cancelled coroutine could leak the
3072
+ # reservation for the process lifetime (or until TTL sweep).
3073
+ settled = False
3074
+ try:
3075
+ response = client.responses.create(
3076
+ model=model,
3077
+ instructions=_REPORT_CODE_SYSTEM_PROMPT,
3078
+ input=attempt_input,
3079
+ max_output_tokens=4096,
3080
+ reasoning={"effort": "low"},
3081
+ )
3082
+ # Log every call for the admin dashboard. Only public_manager rows
3083
+ # count against the demo cap — other roles are logged for
3084
+ # visibility but exempt from rate limiting.
3085
+ usage = getattr(response, "usage", None)
3086
+ demo_limiter.settle(
3087
+ reservation_id,
3088
+ actual_cost_usd=cost_of_openai_call(model, usage),
3089
+ ip=client_ip or "unknown",
3090
+ role=user_role,
3091
+ provider="openai",
3092
+ model=model,
3093
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
3094
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
3095
+ session_id=session_id,
3096
+ )
3097
+ settled = True
3098
+ finally:
3099
+ if not settled:
3100
+ demo_limiter.release(reservation_id)
3101
+
3102
+ response_id = str(getattr(response, "id", None) or "unknown")
3103
+ try:
3104
+ text = getattr(response, "output_text", "") or ""
3105
+ if not text:
3106
+ text = "".join(
3107
+ getattr(block, "text", "") or ""
3108
+ for item in (getattr(response, "output", None) or [])
3109
+ for block in (getattr(item, "content", None) or [])
3110
+ if getattr(block, "type", "") in ("output_text", "text")
3111
+ )
3112
+ spec = _extract_json_object(text)
3113
+ return _validate_report_specialist_spec(spec, response_id=response_id)
3114
+ except Exception as exc:
3115
+ validation_feedback = _report_validation_summary(exc)
3116
+ print(
3117
+ "[report_specialist] "
3118
+ f"response_id={response_id} attempt={attempt} "
3119
+ f"validation_error={_report_validation_log_summary(exc)}",
3120
+ flush=True,
3121
+ )
3122
+ if attempt == max_attempts:
3123
+ raise RuntimeError(
3124
+ "Report specialist returned an invalid spec after "
3125
+ f"{max_attempts} attempts: {validation_feedback}"
3126
+ ) from exc
3127
+
3128
+ raise RuntimeError("Report specialist did not return a report spec.")
2989
3129
 
2990
3130
 
2991
3131
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.31"
3
+ version = "0.8.33"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes