todomat 0.2.1__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 (41) hide show
  1. todomat-0.2.1/PKG-INFO +115 -0
  2. todomat-0.2.1/README.md +94 -0
  3. todomat-0.2.1/pyproject.toml +77 -0
  4. todomat-0.2.1/setup.cfg +4 -0
  5. todomat-0.2.1/tests/test_contracts.py +47 -0
  6. todomat-0.2.1/tests/test_format_incomplete.py +15 -0
  7. todomat-0.2.1/tests/test_nlp2dsl_errors.py +32 -0
  8. todomat-0.2.1/tests/test_nlp2uri_engine.py +21 -0
  9. todomat-0.2.1/tests/test_routing.py +63 -0
  10. todomat-0.2.1/todomat/__init__.py +15 -0
  11. todomat-0.2.1/todomat/domains/__init__.py +1 -0
  12. todomat-0.2.1/todomat/domains/engines/__init__.py +6 -0
  13. todomat-0.2.1/todomat/domains/engines/curllm.py +131 -0
  14. todomat-0.2.1/todomat/domains/engines/errors.py +44 -0
  15. todomat-0.2.1/todomat/domains/engines/iterun.py +149 -0
  16. todomat-0.2.1/todomat/domains/engines/nlp2dsl.py +116 -0
  17. todomat-0.2.1/todomat/domains/engines/nlp2uri.py +69 -0
  18. todomat-0.2.1/todomat/domains/openai/__init__.py +4 -0
  19. todomat-0.2.1/todomat/domains/openai/format.py +73 -0
  20. todomat-0.2.1/todomat/domains/openai/models.py +40 -0
  21. todomat-0.2.1/todomat/domains/orchestrator/__init__.py +3 -0
  22. todomat-0.2.1/todomat/domains/orchestrator/trigger.py +144 -0
  23. todomat-0.2.1/todomat/domains/registry/__init__.py +3 -0
  24. todomat-0.2.1/todomat/domains/registry/client.py +18 -0
  25. todomat-0.2.1/todomat/domains/routing/__init__.py +23 -0
  26. todomat-0.2.1/todomat/domains/routing/intent.py +198 -0
  27. todomat-0.2.1/todomat/domains/shared/__init__.py +16 -0
  28. todomat-0.2.1/todomat/domains/shared/config.py +69 -0
  29. todomat-0.2.1/todomat/domains/shared/contracts.py +87 -0
  30. todomat-0.2.1/todomat.egg-info/PKG-INFO +115 -0
  31. todomat-0.2.1/todomat.egg-info/SOURCES.txt +39 -0
  32. todomat-0.2.1/todomat.egg-info/dependency_links.txt +1 -0
  33. todomat-0.2.1/todomat.egg-info/entry_points.txt +2 -0
  34. todomat-0.2.1/todomat.egg-info/requires.txt +16 -0
  35. todomat-0.2.1/todomat.egg-info/top_level.txt +2 -0
  36. todomat-0.2.1/todomat_mcp/__init__.py +1 -0
  37. todomat-0.2.1/todomat_mcp/backends.py +214 -0
  38. todomat-0.2.1/todomat_mcp/gateway.py +34 -0
  39. todomat-0.2.1/todomat_mcp/routing.py +33 -0
  40. todomat-0.2.1/todomat_mcp/runner.py +122 -0
  41. todomat-0.2.1/todomat_mcp/server.py +94 -0
todomat-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: todomat
3
+ Version: 0.2.1
4
+ Summary: Todomat — TODO orchestration with NLP2DSL, ITERUN, and curllm pipelines
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.26.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: beautifulsoup4>=4.12
11
+ Provides-Extra: mcp
12
+ Requires-Dist: mcp>=1.0.0; extra == "mcp"
13
+ Provides-Extra: services
14
+ Requires-Dist: fastapi>=0.115; extra == "services"
15
+ Requires-Dist: uvicorn[standard]>=0.32; extra == "services"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8.0; extra == "dev"
18
+ Requires-Dist: goal>=2.1.0; extra == "dev"
19
+ Requires-Dist: costs>=0.1.20; extra == "dev"
20
+ Requires-Dist: pfix>=0.1.60; extra == "dev"
21
+
22
+ # Todomat
23
+
24
+
25
+ ## AI Cost Tracking
26
+
27
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.1-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
28
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.15-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-1.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
29
+
30
+ - 🤖 **LLM usage:** $0.1500 (1 commits)
31
+ - 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
32
+
33
+ Generated on 2026-06-07 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
34
+
35
+ ---
36
+
37
+ Stack testowy strony www dla klientów wdrażających automatyzację przez prompty.
38
+
39
+ Łączy **Open WebUI** z **curllm** (dane online), **NLP2DSL** (procesy biznesowe) i **ITERUN** (usługi/API/stacki) przez OpenAI-compatible router.
40
+
41
+ ## Szybki start
42
+
43
+ ```bash
44
+ cp .env.example .env
45
+ ./scripts/bootstrap.sh
46
+ ./scripts/test-local.sh
47
+ ```
48
+
49
+ ## Endpointy
50
+
51
+ | Usługa | URL |
52
+ |--------|-----|
53
+ | Open WebUI | http://localhost:3000 |
54
+ | Pipeline Router | http://localhost:9099/v1 |
55
+ | Trigger Gateway | http://localhost:8084/trigger |
56
+ | curllm API (opcjonalnie) | http://localhost:8810 |
57
+
58
+ ## curllm — dane z internetu
59
+
60
+ ```bash
61
+ ./scripts/start-curllm.sh # opcjonalnie, pełny browser+LLM
62
+ ```
63
+
64
+ Domyślnie adapter używa lekkiego HTTP fetch (`CURLLM_LIGHT_FETCH=1`). Pełny curllm: `CURLLM_LIGHT_FETCH=0` + uruchomiony API na :8810.
65
+
66
+ ## Open WebUI — połączenie
67
+
68
+ Admin → Settings → Connections:
69
+
70
+ ```
71
+ Base URL: http://pipeline-router:9099/v1 # gdy Open WebUI w sieci todomat
72
+ API Key: dev-key
73
+ ```
74
+
75
+ Jeśli Open WebUI działa poza compose: `http://host.docker.internal:9099/v1`.
76
+ Po `./scripts/bootstrap.sh` kontener `openwebui` jest automatycznie podłączany do sieci `todomat_todomat-net`.
77
+
78
+ ## Przykładowe prompty
79
+
80
+ Zob. `examples/prompts/` — w tym [orchestration.md](examples/prompts/orchestration.md) (usługi, getv, desktop, workflow).
81
+
82
+ ## MCP (Cursor / agenty)
83
+
84
+ Jeden router MCP routuje do curllm, nlp2dsl i iterun:
85
+
86
+ ```bash
87
+ pip install -e .
88
+ ./scripts/start-mcp.sh
89
+ ```
90
+
91
+ Dokumentacja: [docs/MCP.md](docs/MCP.md) · konfiguracja Cursor: `examples/mcp-cursor.json`
92
+
93
+ ## Orchestracja (URI, MCP, wiele usług)
94
+
95
+ Przewodnik uruchomienia i kontroli usług, artefaktów, zmiennych getv i zadań:
96
+
97
+ - **[docs/ORCHESTRATION.md](docs/ORCHESTRATION.md)** — trigger-gateway, MCP, scenariusze
98
+ - [docs/MCP.md](docs/MCP.md) — router `todomat-mcp`
99
+ - [docs/INTEGRATION.md](docs/INTEGRATION.md) — Open WebUI
100
+ - [nlp2uri orchestration](https://github.com/semcod/nlp2uri/blob/main/docs/orchestration.md) — schematy URI
101
+
102
+ ```bash
103
+ ./scripts/sync-env-from-getv.sh llm/groq # zmienne → .env
104
+ ./scripts/install-mcp-stack.sh # MCP + nlp2uri + getv
105
+ ```
106
+
107
+ ## Dokumentacja
108
+
109
+ - [docs/ORCHESTRATION.md](docs/ORCHESTRATION.md)
110
+ - [docs/INTEGRATION.md](docs/INTEGRATION.md)
111
+
112
+
113
+ ## License
114
+
115
+ Licensed under Apache-2.0.
@@ -0,0 +1,94 @@
1
+ # Todomat
2
+
3
+
4
+ ## AI Cost Tracking
5
+
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.1-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$0.15-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-1.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
+
9
+ - 🤖 **LLM usage:** $0.1500 (1 commits)
10
+ - 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
11
+
12
+ Generated on 2026-06-07 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
+
14
+ ---
15
+
16
+ Stack testowy strony www dla klientów wdrażających automatyzację przez prompty.
17
+
18
+ Łączy **Open WebUI** z **curllm** (dane online), **NLP2DSL** (procesy biznesowe) i **ITERUN** (usługi/API/stacki) przez OpenAI-compatible router.
19
+
20
+ ## Szybki start
21
+
22
+ ```bash
23
+ cp .env.example .env
24
+ ./scripts/bootstrap.sh
25
+ ./scripts/test-local.sh
26
+ ```
27
+
28
+ ## Endpointy
29
+
30
+ | Usługa | URL |
31
+ |--------|-----|
32
+ | Open WebUI | http://localhost:3000 |
33
+ | Pipeline Router | http://localhost:9099/v1 |
34
+ | Trigger Gateway | http://localhost:8084/trigger |
35
+ | curllm API (opcjonalnie) | http://localhost:8810 |
36
+
37
+ ## curllm — dane z internetu
38
+
39
+ ```bash
40
+ ./scripts/start-curllm.sh # opcjonalnie, pełny browser+LLM
41
+ ```
42
+
43
+ Domyślnie adapter używa lekkiego HTTP fetch (`CURLLM_LIGHT_FETCH=1`). Pełny curllm: `CURLLM_LIGHT_FETCH=0` + uruchomiony API na :8810.
44
+
45
+ ## Open WebUI — połączenie
46
+
47
+ Admin → Settings → Connections:
48
+
49
+ ```
50
+ Base URL: http://pipeline-router:9099/v1 # gdy Open WebUI w sieci todomat
51
+ API Key: dev-key
52
+ ```
53
+
54
+ Jeśli Open WebUI działa poza compose: `http://host.docker.internal:9099/v1`.
55
+ Po `./scripts/bootstrap.sh` kontener `openwebui` jest automatycznie podłączany do sieci `todomat_todomat-net`.
56
+
57
+ ## Przykładowe prompty
58
+
59
+ Zob. `examples/prompts/` — w tym [orchestration.md](examples/prompts/orchestration.md) (usługi, getv, desktop, workflow).
60
+
61
+ ## MCP (Cursor / agenty)
62
+
63
+ Jeden router MCP routuje do curllm, nlp2dsl i iterun:
64
+
65
+ ```bash
66
+ pip install -e .
67
+ ./scripts/start-mcp.sh
68
+ ```
69
+
70
+ Dokumentacja: [docs/MCP.md](docs/MCP.md) · konfiguracja Cursor: `examples/mcp-cursor.json`
71
+
72
+ ## Orchestracja (URI, MCP, wiele usług)
73
+
74
+ Przewodnik uruchomienia i kontroli usług, artefaktów, zmiennych getv i zadań:
75
+
76
+ - **[docs/ORCHESTRATION.md](docs/ORCHESTRATION.md)** — trigger-gateway, MCP, scenariusze
77
+ - [docs/MCP.md](docs/MCP.md) — router `todomat-mcp`
78
+ - [docs/INTEGRATION.md](docs/INTEGRATION.md) — Open WebUI
79
+ - [nlp2uri orchestration](https://github.com/semcod/nlp2uri/blob/main/docs/orchestration.md) — schematy URI
80
+
81
+ ```bash
82
+ ./scripts/sync-env-from-getv.sh llm/groq # zmienne → .env
83
+ ./scripts/install-mcp-stack.sh # MCP + nlp2uri + getv
84
+ ```
85
+
86
+ ## Dokumentacja
87
+
88
+ - [docs/ORCHESTRATION.md](docs/ORCHESTRATION.md)
89
+ - [docs/INTEGRATION.md](docs/INTEGRATION.md)
90
+
91
+
92
+ ## License
93
+
94
+ Licensed under Apache-2.0.
@@ -0,0 +1,77 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "todomat"
7
+ version = "0.2.1"
8
+ description = "Todomat — TODO orchestration with NLP2DSL, ITERUN, and curllm pipelines"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ dependencies = [
13
+ "httpx>=0.26.0",
14
+ "pydantic>=2.0",
15
+ "beautifulsoup4>=4.12",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ mcp = [
20
+ "mcp>=1.0.0",
21
+ ]
22
+ services = [
23
+ "fastapi>=0.115",
24
+ "uvicorn[standard]>=0.32",
25
+ ]
26
+ dev = [
27
+ "pytest>=8.0",
28
+ "goal>=2.1.0",
29
+ "costs>=0.1.20",
30
+ "pfix>=0.1.60",
31
+ ]
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ ]
37
+
38
+ [project.scripts]
39
+ todomat-mcp = "todomat_mcp.server:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ include = ["todomat*", "todomat_mcp*"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ python_files = ["test_*.py"]
47
+ pythonpath = ["."]
48
+ addopts = "-v --tb=short"
49
+
50
+ [tool.pfix]
51
+ # Self-healing Python configuration
52
+ model = "openrouter/qwen/qwen3-coder-next"
53
+ auto_apply = true
54
+ auto_install_deps = true
55
+ auto_restart = false
56
+ max_retries = 3
57
+ create_backups = false
58
+ git_auto_commit = false
59
+
60
+ [tool.pfix.runtime_todo]
61
+ enabled = true
62
+ todo_file = "TODO.md"
63
+ min_severity = "low"
64
+ deduplicate = true
65
+
66
+ [tool.costs]
67
+ # AI Cost tracking configuration
68
+ badge = true
69
+ update_readme = true
70
+ readme_path = "README.md"
71
+ default_model = "openrouter/qwen/qwen3-coder-next"
72
+ analysis_mode = "byok"
73
+ full_history = true
74
+ max_commits = 500
75
+
76
+ # Cost thresholds for badge colors (USD)
77
+ badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ """Domain contract tests."""
2
+
3
+ import pytest
4
+
5
+ from todomat.domains.engines.nlp2dsl import Nlp2dslEngine, mock_workflow
6
+ from todomat.domains.routing.intent import resolve_plan
7
+ from todomat.domains.shared.config import _mock_for_engine
8
+ from todomat.domains.shared.contracts import TodoIntentRequest, TodoResult
9
+ from todomat.domains.shared.config import EngineConfig
10
+
11
+
12
+ def test_resolve_plan_with_model():
13
+ plan = resolve_plan(TodoIntentRequest(text="pobierz https://x.com", model="curllm-web-research"))
14
+ assert plan.pipeline == "curllm"
15
+ assert plan.execute is True
16
+
17
+
18
+ def test_todo_result_roundtrip():
19
+ payload = {"status": "complete", "summary": "ok", "dsl": {"steps": []}, "mock": True}
20
+ result = TodoResult.from_engine_payload(payload, pipeline="nlp2dsl")
21
+ data = result.to_dict()
22
+ assert data["pipeline"] == "nlp2dsl"
23
+ assert data["dsl"] == {"steps": []}
24
+
25
+
26
+ def test_nlp2dsl_mock_engine():
27
+ import asyncio
28
+
29
+ engine = Nlp2dslEngine(EngineConfig(mock=True, upstream_url="http://localhost:8010"))
30
+ out = asyncio.run(engine.process("wyślij fakturę 200 PLN", execute=False))
31
+ assert out["mock"] is True
32
+ assert "send_invoice" in str(out["dsl"])
33
+
34
+
35
+ def test_mock_workflow_invoice_amount():
36
+ out = mock_workflow("faktura 999 PLN", execute=True)
37
+ step = out["dsl"]["steps"][0]
38
+ assert step["config"]["amount"] == 999.0
39
+
40
+
41
+ def test_per_engine_mock_env(monkeypatch):
42
+ monkeypatch.delenv("MOCK_MODE", raising=False)
43
+ monkeypatch.setenv("NLP2DSL_MOCK_MODE", "0")
44
+ assert _mock_for_engine("nlp2dsl") is False
45
+ monkeypatch.delenv("NLP2DSL_MOCK_MODE", raising=False)
46
+ monkeypatch.setenv("MOCK_MODE", "1")
47
+ assert _mock_for_engine("nlp2dsl") is True
@@ -0,0 +1,15 @@
1
+ from todomat.domains.openai.format import format_chat_response
2
+
3
+
4
+ def test_incomplete_shows_prompt_user():
5
+ result = {
6
+ "status": "incomplete",
7
+ "pipeline": "nlp2dsl",
8
+ "summary": "Status: incomplete",
9
+ "prompt_user": "Podaj: adres e-mail odbiorcy, treść wiadomości (body)",
10
+ "missing_fields": ["send_email.to", "send_email.body"],
11
+ }
12
+ text = format_chat_response("orchestrator-auto", result)
13
+ assert "Podaj:" in text
14
+ assert "send_email.to" in text
15
+ assert "incomplete" in text.lower() or "uzupełnienia" in text.lower()
@@ -0,0 +1,32 @@
1
+ from todomat.domains.engines.errors import normalize_nlp2dsl_prompt, parse_upstream_error
2
+ from todomat.domains.openai.format import format_chat_response
3
+
4
+
5
+ class _FakeResponse:
6
+ def __init__(self, payload: dict, status_code: int = 422) -> None:
7
+ self._payload = payload
8
+ self.status_code = status_code
9
+
10
+ def json(self) -> dict:
11
+ return self._payload
12
+
13
+
14
+ def test_normalize_z_trescja():
15
+ text = "Wyślij email do jan@firma.pl z treścią: Dzień dobry"
16
+ assert "treść:" in normalize_nlp2dsl_prompt(text).lower()
17
+
18
+
19
+ def test_parse_nested_upstream_error():
20
+ resp = _FakeResponse({"detail": {"detail": {"error": "Nie rozpoznano intencji", "hint": "Spróbuj fakturę"}}})
21
+ out = parse_upstream_error(resp)
22
+ assert out["status"] == "error"
23
+ assert out["hint"] == "Spróbuj fakturę"
24
+
25
+
26
+ def test_format_error_response():
27
+ text = format_chat_response(
28
+ "orchestrator-auto",
29
+ {"status": "error", "pipeline": "nlp2dsl", "error": "Nie rozpoznano intencji", "hint": "Spróbuj fakturę"},
30
+ )
31
+ assert "Wskazówka" in text
32
+ assert "Spróbuj fakturę" in text
@@ -0,0 +1,21 @@
1
+ """nlp2uri engine mock tests."""
2
+
3
+ import asyncio
4
+
5
+ from todomat.domains.engines.nlp2uri import Nlp2uriEngine
6
+ from todomat.domains.shared.config import EngineConfig
7
+
8
+
9
+ def test_nlp2uri_engine_mock_desktop():
10
+ engine = Nlp2uriEngine(EngineConfig(mock=True, upstream_url=""))
11
+ result = asyncio.run(engine.process("otwórz firefox", execute=False))
12
+ assert result["status"] == "ok"
13
+ assert result["mock"] is True
14
+ assert "firefox" in result["plan"]["uri"]
15
+
16
+
17
+ def test_nlp2uri_engine_mock_getv():
18
+ engine = Nlp2uriEngine(EngineConfig(mock=True, upstream_url=""))
19
+ result = asyncio.run(engine.process("pokaż GROQ_API_KEY z getv", execute=False))
20
+ assert result["source"] == "getv"
21
+ assert result["uri"].startswith("getv://")
@@ -0,0 +1,63 @@
1
+ """Routing logic tests — no MCP subprocess."""
2
+
3
+ from todomat_mcp.routing import (
4
+ detect_pipeline,
5
+ downstream_target,
6
+ parse_execute_flag,
7
+ route_decision,
8
+ route_model,
9
+ )
10
+
11
+
12
+ def test_detect_curllm_online():
13
+ assert detect_pipeline("pobierz dane z https://example.com") == "online-auto"
14
+
15
+
16
+ def test_detect_iterun_service():
17
+ assert detect_pipeline("utwórz mikroserwis fastapi z redis") == "iterun"
18
+
19
+
20
+ def test_detect_iterun_web_contact_app():
21
+ assert detect_pipeline("stworz aplikacje web kontaktu dla firmy") == "iterun"
22
+ assert route_model("orchestrator-auto", "stworz aplikacje web kontaktu dla firmy") == "iterun"
23
+
24
+
25
+ def test_detect_nlp2dsl_default():
26
+ assert detect_pipeline("wyślij fakturę do klienta po opłaceniu zamówienia") == "nlp2dsl"
27
+
28
+
29
+ def test_downstream_after_web():
30
+ assert downstream_target("pobierz dane i wygeneruj stack docker") == "iterun"
31
+ assert downstream_target("pobierz dane i zautomatyzuj proces faktury") == "nlp2dsl"
32
+
33
+
34
+ def test_route_model_mapping():
35
+ assert route_model("curllm-web-research", "x") == "curllm"
36
+ assert route_model("orchestrator-online", "x") == "online-auto"
37
+ assert route_model("iterun-service-builder", "x") == "iterun"
38
+
39
+
40
+ def test_parse_execute_flag_default_execute():
41
+ text, execute = parse_execute_flag("wyślij fakturę do klienta")
42
+ assert execute is True
43
+ assert text == "wyślij fakturę do klienta"
44
+
45
+
46
+ def test_parse_execute_flag_dry_run():
47
+ text, execute = parse_execute_flag("wyślij fakturę --dry-run")
48
+ assert execute is False
49
+ assert "--dry-run" not in text
50
+
51
+
52
+ def test_detect_nlp2uri_desktop():
53
+ assert detect_pipeline("otwórz firefox") == "nlp2uri"
54
+
55
+
56
+ def test_detect_nlp2uri_getv():
57
+ assert detect_pipeline("pokaż GROQ_API_KEY z getv") == "nlp2uri"
58
+
59
+
60
+ def test_route_decision_auto():
61
+ d = route_decision("zbuduj api gateway", pipeline="auto")
62
+ assert d["pipeline"] == "iterun"
63
+ assert d["service_hints"] is True
@@ -0,0 +1,15 @@
1
+ """Todomat core — shared contracts, routing, engines, and orchestration."""
2
+
3
+ from todomat.domains.shared.contracts import (
4
+ ProcessRequest,
5
+ TodoIntentRequest,
6
+ TodoPlan,
7
+ TodoResult,
8
+ )
9
+
10
+ __all__ = [
11
+ "ProcessRequest",
12
+ "TodoIntentRequest",
13
+ "TodoPlan",
14
+ "TodoResult",
15
+ ]
@@ -0,0 +1 @@
1
+ """Domain layer — contracts, routing, engines, orchestration."""
@@ -0,0 +1,6 @@
1
+ from todomat.domains.engines.curllm import CurllmEngine
2
+ from todomat.domains.engines.iterun import IterunEngine
3
+ from todomat.domains.engines.nlp2dsl import Nlp2dslEngine
4
+ from todomat.domains.engines.nlp2uri import Nlp2uriEngine
5
+
6
+ __all__ = ["CurllmEngine", "IterunEngine", "Nlp2dslEngine", "Nlp2uriEngine"]
@@ -0,0 +1,131 @@
1
+ """curllm web-research engine — light HTTP, upstream API, or mock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from bs4 import BeautifulSoup
11
+
12
+ from todomat.domains.shared.config import CurllmEngineConfig
13
+
14
+ URL_RE = re.compile(r"https?://[^\s<>\"']+", re.I)
15
+
16
+
17
+ def extract_url(text: str, explicit: str | None = None) -> str | None:
18
+ if explicit:
19
+ return explicit.strip()
20
+ match = URL_RE.search(text)
21
+ return match.group(0).rstrip(".,);]") if match else None
22
+
23
+
24
+ def instruction_from_text(text: str, url: str) -> str:
25
+ cleaned = text.replace(url, "").strip()
26
+ if cleaned:
27
+ return cleaned
28
+ return "extract page title, main text summary and all links"
29
+
30
+
31
+ def light_fetch(url: str, instruction: str) -> dict[str, Any]:
32
+ with httpx.Client(timeout=30.0, follow_redirects=True) as client:
33
+ resp = client.get(url, headers={"User-Agent": "todomat-curllm-adapter/0.1"})
34
+ resp.raise_for_status()
35
+ html = resp.text
36
+
37
+ soup = BeautifulSoup(html, "html.parser")
38
+ title = (soup.title.string or "").strip() if soup.title else ""
39
+ paragraphs = [p.get_text(" ", strip=True) for p in soup.find_all("p")[:8]]
40
+ text_summary = " ".join(p for p in paragraphs if p)[:2000]
41
+ links = []
42
+ for a in soup.find_all("a", href=True)[:30]:
43
+ href = a["href"]
44
+ if href.startswith("http"):
45
+ links.append({"text": a.get_text(strip=True)[:120], "href": href})
46
+
47
+ return {
48
+ "mode": "light_http",
49
+ "url": url,
50
+ "instruction": instruction,
51
+ "title": title,
52
+ "summary": text_summary or title,
53
+ "links": links,
54
+ "status_code": resp.status_code,
55
+ }
56
+
57
+
58
+ async def curllm_fetch(url: str, instruction: str, upstream: str) -> dict[str, Any]:
59
+ async with httpx.AsyncClient(timeout=300.0) as client:
60
+ resp = await client.post(
61
+ f"{upstream.rstrip('/')}/api/execute",
62
+ json={
63
+ "url": url,
64
+ "data": instruction,
65
+ "visual_mode": False,
66
+ "stealth_mode": False,
67
+ "headers": {"Accept-Language": "pl-PL,pl;q=0.9"},
68
+ },
69
+ )
70
+ resp.raise_for_status()
71
+ data = resp.json()
72
+
73
+ return {
74
+ "mode": "curllm",
75
+ "url": url,
76
+ "instruction": instruction,
77
+ "success": data.get("success", True),
78
+ "result": data.get("result") or data,
79
+ "steps_taken": data.get("steps_taken"),
80
+ "run_log": data.get("run_log"),
81
+ }
82
+
83
+
84
+ def context_block(web: dict[str, Any]) -> str:
85
+ payload = {k: v for k, v in web.items() if k not in {"raw"}}
86
+ return json.dumps(payload, ensure_ascii=False, indent=2)
87
+
88
+
89
+ class CurllmEngine:
90
+ def __init__(self, config: CurllmEngineConfig) -> None:
91
+ self.config = config
92
+
93
+ async def fetch_web(
94
+ self,
95
+ text: str,
96
+ *,
97
+ url: str | None = None,
98
+ instruction: str | None = None,
99
+ ) -> dict[str, Any]:
100
+ target_url = extract_url(text, url)
101
+ if not target_url:
102
+ raise ValueError("Brak URL w prompcie — podaj https://...")
103
+
104
+ instr = instruction or instruction_from_text(text, target_url)
105
+
106
+ if self.config.mock or self.config.light_fetch:
107
+ try:
108
+ return light_fetch(target_url, instr)
109
+ except Exception as exc:
110
+ if self.config.mock:
111
+ return {
112
+ "mode": "mock",
113
+ "url": target_url,
114
+ "instruction": instr,
115
+ "summary": f"Mock fetch dla {target_url}",
116
+ "links": [],
117
+ "error": str(exc),
118
+ }
119
+ raise
120
+
121
+ return await curllm_fetch(target_url, instr, self.config.upstream_url)
122
+
123
+ async def process(self, text: str, *, execute: bool = False) -> dict[str, Any]:
124
+ web = await self.fetch_web(text)
125
+ return {
126
+ "status": "complete",
127
+ "summary": f"Pobrano dane online z {web.get('url')} ({web.get('mode')})",
128
+ "web_data": web,
129
+ "context": context_block(web),
130
+ "mock": web.get("mode") in {"mock", "light_http"},
131
+ }
@@ -0,0 +1,44 @@
1
+ """Parse upstream HTTP errors into user-facing payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ def normalize_nlp2dsl_prompt(text: str) -> str:
11
+ """Rewrite common Polish phrasing so NLP2DSL rules parser recognizes fields."""
12
+ import re
13
+
14
+ text = re.sub(r"\bz\s+treścią\s*:", "treść:", text, flags=re.IGNORECASE)
15
+ text = re.sub(r"\bz\s+trescia\s*:", "treść:", text, flags=re.IGNORECASE)
16
+ return text
17
+
18
+
19
+ def parse_upstream_error(resp: httpx.Response) -> dict[str, Any]:
20
+ try:
21
+ data = resp.json()
22
+ except Exception:
23
+ return {"status": "error", "summary": (resp.text or "Błąd upstream")[:500]}
24
+
25
+ detail: Any = data
26
+ for _ in range(5):
27
+ if isinstance(detail, dict) and set(detail.keys()) == {"detail"}:
28
+ detail = detail["detail"]
29
+ else:
30
+ break
31
+
32
+ if isinstance(detail, dict):
33
+ error = detail.get("error")
34
+ hint = detail.get("hint")
35
+ summary = hint or error or detail.get("message") or str(detail)
36
+ return {
37
+ "status": "error",
38
+ "summary": summary,
39
+ "error": error,
40
+ "hint": hint,
41
+ "text": detail.get("text"),
42
+ }
43
+
44
+ return {"status": "error", "summary": str(detail)[:500]}