ihalent 0.1.0__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 (35) hide show
  1. ihalent-0.1.0/.github/workflows/ci.yml +41 -0
  2. ihalent-0.1.0/.gitignore +22 -0
  3. ihalent-0.1.0/CHANGELOG.md +21 -0
  4. ihalent-0.1.0/CONTRIBUTING.md +31 -0
  5. ihalent-0.1.0/LICENSE +21 -0
  6. ihalent-0.1.0/PKG-INFO +224 -0
  7. ihalent-0.1.0/README.md +191 -0
  8. ihalent-0.1.0/docs/schema.md +64 -0
  9. ihalent-0.1.0/examples/README.md +25 -0
  10. ihalent-0.1.0/examples/build_sample.py +63 -0
  11. ihalent-0.1.0/examples/notices/2025-2174700.md +25 -0
  12. ihalent-0.1.0/examples/notices/2025-2198370.md +25 -0
  13. ihalent-0.1.0/examples/notices/2025-2337797.md +25 -0
  14. ihalent-0.1.0/examples/notices/2025-2388267.md +26 -0
  15. ihalent-0.1.0/examples/sample-awards.jsonl +4 -0
  16. ihalent-0.1.0/pyproject.toml +96 -0
  17. ihalent-0.1.0/src/ihalent/__init__.py +29 -0
  18. ihalent-0.1.0/src/ihalent/analytics.py +280 -0
  19. ihalent-0.1.0/src/ihalent/cli.py +187 -0
  20. ihalent-0.1.0/src/ihalent/ingest.py +108 -0
  21. ihalent-0.1.0/src/ihalent/mcp_server.py +110 -0
  22. ihalent-0.1.0/src/ihalent/model.py +111 -0
  23. ihalent-0.1.0/src/ihalent/normalize.py +64 -0
  24. ihalent-0.1.0/src/ihalent/parse.py +168 -0
  25. ihalent-0.1.0/src/ihalent/py.typed +0 -0
  26. ihalent-0.1.0/src/ihalent/render.py +103 -0
  27. ihalent-0.1.0/src/ihalent/store.py +49 -0
  28. ihalent-0.1.0/tests/conftest.py +60 -0
  29. ihalent-0.1.0/tests/test_analytics.py +108 -0
  30. ihalent-0.1.0/tests/test_ingest.py +85 -0
  31. ihalent-0.1.0/tests/test_mcp_tools.py +95 -0
  32. ihalent-0.1.0/tests/test_normalize.py +29 -0
  33. ihalent-0.1.0/tests/test_parse.py +91 -0
  34. ihalent-0.1.0/tests/test_store_cli.py +142 -0
  35. ihalent-0.1.0/uv.lock +1349 -0
@@ -0,0 +1,41 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest, windows-latest]
14
+ python: ["3.10", "3.14"]
15
+ runs-on: ${{ matrix.os }}
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: astral-sh/setup-uv@v5
20
+ with:
21
+ python-version: ${{ matrix.python }}
22
+
23
+ - name: Install
24
+ run: uv sync --dev
25
+
26
+ - name: Lint
27
+ run: uv run ruff check src tests
28
+
29
+ - name: Types
30
+ run: uv run mypy src
31
+
32
+ - name: Tests
33
+ run: uv run pytest
34
+
35
+ - name: The bundled example must rebuild and analyze cleanly
36
+ shell: bash
37
+ run: |
38
+ uv run python examples/build_sample.py
39
+ # --json is ASCII by contract, so this is safe on any console code page;
40
+ # the negative-discount case must survive the round trip.
41
+ uv run ihalent overview examples/sample-awards.jsonl --json | grep -q '"min": -2.96'
@@ -0,0 +1,22 @@
1
+ # python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+
9
+ # tooling caches
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+
14
+ # editors / os
15
+ .vscode/
16
+ .idea/
17
+ Thumbs.db
18
+ .DS_Store
19
+
20
+ # local award data (users collect their own; only the example ships)
21
+ /awards.jsonl
22
+ /bundle.json
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-07-17
4
+
5
+ First public release.
6
+
7
+ - Parse EKAP result notices (Sonuç İlanı) into structured `Award` records: İKN, estimate,
8
+ contract price, winner (with joint-venture members), bid counts, dates — deterministically,
9
+ with missing fields left `None` rather than guessed.
10
+ - Analytics: firm profiles (folded across spelling variants), discount (kırım) distributions
11
+ grouped by authority / province / tender type, and competition stats including single-bid
12
+ share.
13
+ - The coverage discipline: every statistic reports how many records it used and how many it
14
+ dropped for missing data; discounts are never computed from a missing estimate.
15
+ - `ingest` from ihale-mcp / EKAP output (pure function, no network), plus `overview`, `firm`,
16
+ `discounts` and `parse` commands, each with a `--json` form.
17
+ - Optional MCP server (`pip install 'ihalent[mcp]'`, `ihalent-mcp`) exposing the analytics as
18
+ agent tools over a dataset from `IHALENT_AWARDS`.
19
+ - Worked example built from four real December-2025 awards across four authority types,
20
+ including a negative-discount emergency-procurement case and the code that structures them.
21
+ - 44 tests over the exact JSON shapes EKAP and ihale-mcp emit.
@@ -0,0 +1,31 @@
1
+ # Contributing
2
+
3
+ Thanks for considering it. ihalent stays useful only if it stays honest about what it does
4
+ and does not know, so a few rules protect that.
5
+
6
+ ## Ground rules
7
+
8
+ - **The core is a pure function of its input.** No network calls, no signing keys, no live
9
+ scraping in `parse`, `analytics`, `ingest` or `store`. Collection is ihale-mcp's job.
10
+ - **Never invent a number.** A missing field is `None`, not `0` or `""`. A statistic reports
11
+ its coverage. If a change would let a mean hide how many records lacked the value, it's the
12
+ wrong change.
13
+ - **Parsing changes ship with a real notice.** The tests are built on the exact markdown EKAP
14
+ serves. If you handle a new notice variant, add the real (trimmed) notice that motivated it
15
+ to `tests/` so the parser is pinned to reality, not to a tidy invention.
16
+ - **Company-name folding errs toward splitting, not merging.** Merging two real firms into one
17
+ row corrupts the analysis silently; showing two rows for one firm is visible and fixable.
18
+ New normalization rules need a test that they don't over-merge.
19
+
20
+ ## Mechanics
21
+
22
+ ```
23
+ uv sync --dev
24
+ uv run pytest
25
+ uv run ruff check src tests
26
+ uv run mypy src
27
+ ```
28
+
29
+ All three must be clean; CI runs them on Linux and Windows. Keep PRs scoped to one change,
30
+ and if you touch the parser or the example, re-run `python examples/build_sample.py` in the
31
+ same PR so the committed sample matches the code.
ihalent-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mehmet Eren Gülmez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ihalent-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: ihalent
3
+ Version: 0.1.0
4
+ Summary: Turkish public-tender award intelligence: who won, for how much, at what discount, against how many bidders.
5
+ Project-URL: Homepage, https://github.com/gulmezeren2-byte/ihalent
6
+ Project-URL: Repository, https://github.com/gulmezeren2-byte/ihalent
7
+ Project-URL: Issues, https://github.com/gulmezeren2-byte/ihalent/issues
8
+ Project-URL: Changelog, https://github.com/gulmezeren2-byte/ihalent/blob/main/CHANGELOG.md
9
+ Author: Mehmet Eren Gülmez
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,analytics,ekap,government-data,mcp,public-procurement,tenders,turkey
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Financial and Insurance Industry
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Office/Business :: Financial
25
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: rich>=13.7
29
+ Requires-Dist: typer>=0.12
30
+ Provides-Extra: mcp
31
+ Requires-Dist: mcp>=1.2; extra == 'mcp'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # ihalent
35
+
36
+ **Turkish public-tender award intelligence: who won, for how much, at what discount, against how many bidders.**
37
+
38
+ [![CI](https://github.com/gulmezeren2-byte/ihalent/actions/workflows/ci.yml/badge.svg)](https://github.com/gulmezeren2-byte/ihalent/actions/workflows/ci.yml)
39
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
40
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
41
+
42
+ Every public tender in Turkey ends with a *Sonuç İlanı* — a result notice that states the
43
+ estimate, the contract price, the winner, and how many firms bid. The notices are public.
44
+ They are also one at a time, unstructured, and impossible to reason across: you cannot ask
45
+ EKAP "what has this company won in the last two years" or "how far below estimate does this
46
+ authority actually award." ihalent turns those notices into structured records and answers
47
+ exactly those questions — firm histories, discount (kırım) distributions, and competition
48
+ metrics — with every number traceable back to the notice it came from.
49
+
50
+ ```
51
+ $ ihalent overview awards.jsonl
52
+
53
+ Dataset overview
54
+ 4 tenders: 4 awarded, 0 cancelled
55
+ total awarded value: 677.878.066,48 TL
56
+ Discount (kırım): mean 18.22% median 20.99% min -2.96% max 33.87%
57
+ based on all 4 awards.
58
+ competition: 5.75 valid bids on average (median 4.0); 0.0% had a single valid bid
59
+ data gaps: 0 awards without an estimate, 0 without a bid count.
60
+ ```
61
+
62
+ That is real output over four real December-2025 awards (in
63
+ [`examples/`](examples/)). The `-2.96%` is not a bug: Istanbul University awarded a
64
+ 615-million-lira campus job at **2.96% above** its own estimate, through an emergency
65
+ "pazarlık" procedure — a contract signed over the public estimate, which is precisely the
66
+ kind of thing that should be easy to see and currently is not.
67
+
68
+ ## Why this exists
69
+
70
+ I'm an industrial engineer and I work in construction. When our firm weighs a public
71
+ tender, the questions that decide the bid are all about other people's history: how far
72
+ below estimate does this authority award, how many firms usually show up, what has a given
73
+ competitor been winning and at what price. All of that is public — it is sitting in tens of
74
+ thousands of result notices on EKAP — and none of it is queryable. So everyone rebuilds a
75
+ private, partial version of it by hand, in spreadsheets, badly.
76
+
77
+ There is already an excellent tool for *reaching* this data:
78
+ [saidsurucu/ihale-mcp](https://github.com/saidsurucu/ihale-mcp) solved authenticated EKAP
79
+ access and hands you the notices. ihalent is the layer above it — the one that turns a pile
80
+ of notices into an answer. It does not re-scrape EKAP; it structures what you have collected
81
+ and does the analytics that don't exist yet.
82
+
83
+ ## What it answers
84
+
85
+ **A firm's history** — every award across the dataset, folded across spelling variants:
86
+
87
+ ```
88
+ $ ihalent firm awards.jsonl "ÖZDEN YEL"
89
+
90
+ ÖZDEN YEL
91
+ wins: 1 joint ventures: 1 total contract value: 19.709.997,40 TL
92
+ Discount (kırım): mean 33.87%
93
+ top awarding authorities:
94
+ 1x DSİ 14. Bölge Müdürlüğü
95
+ ```
96
+
97
+ **Where the discounts are** — grouped and sorted, highest first:
98
+
99
+ ```
100
+ $ ihalent discounts awards.jsonl --by authority
101
+
102
+ Mean discount by authority
103
+ DSİ 14. Bölge Müdürlüğü 33.87% 1/1
104
+ Esenler Belediyesi 21.99% 1/1
105
+ Ağrı İl Özel İdaresi 19.99% 1/1
106
+ İstanbul Üniversitesi-Cerrahpaşa -2.96% 1/1
107
+ ```
108
+
109
+ **Competition** — how many valid bids show up, and how often exactly one does (a single-bid
110
+ award is a flag procurement watchdogs care about). It's in the overview above.
111
+
112
+ ## The one rule
113
+
114
+ **A number is never shown without the ground it stands on.** A mean discount always comes
115
+ with "over how many awards, and how many were dropped for a missing estimate." A firm's
116
+ total contract value says so when some of its wins had no published price, so you read it as
117
+ a floor, not the full figure. A discount is `None`, never `0`, when the estimate wasn't
118
+ published — ihalent does not invent the numbers the government didn't print. Half the value
119
+ of a tool like this is refusing to guess.
120
+
121
+ ## Install
122
+
123
+ ```
124
+ pip install git+https://github.com/gulmezeren2-byte/ihalent
125
+ ```
126
+
127
+ A PyPI release (`pip install ihalent`) follows once the first users have kicked the tires.
128
+
129
+ ## The workflow
130
+
131
+ ihalent reads a JSONL file of awards. You produce it by collecting result notices — the
132
+ easy path is ihale-mcp — and letting ihalent structure them:
133
+
134
+ ```
135
+ # 1. collect: with ihale-mcp connected to your agent, save what its
136
+ # get_tender_announcements returns (one or many tenders) to bundle.json
137
+
138
+ # 2. structure:
139
+ ihalent ingest bundle.json -o awards.jsonl
140
+
141
+ # 3. ask:
142
+ ihalent overview awards.jsonl
143
+ ihalent firm awards.jsonl "ACME İNŞAAT"
144
+ ihalent discounts awards.jsonl --by province --min 5
145
+ ```
146
+
147
+ Every command takes `--json` for pipelines and agents. Or skip collection and try the
148
+ bundled example directly:
149
+
150
+ ```
151
+ git clone https://github.com/gulmezeren2-byte/ihalent && cd ihalent
152
+ python examples/build_sample.py # parses the real notices in examples/notices/
153
+ ihalent overview examples/sample-awards.jsonl
154
+ ```
155
+
156
+ ## Commands
157
+
158
+ | command | what it does |
159
+ |---|---|
160
+ | `ihalent overview AWARDS` | value, discount, competition and the data gaps of a dataset |
161
+ | `ihalent firm AWARDS "NAME"` | one company's wins, total value, discount, and where it wins |
162
+ | `ihalent discounts AWARDS --by X` | mean/median discount grouped by authority, province or tender_type |
163
+ | `ihalent parse NOTICE.md` | one result notice → structured JSON |
164
+ | `ihalent ingest BUNDLE.json` | collected ihale-mcp/EKAP output → awards JSONL |
165
+
166
+ ## Using it with AI agents
167
+
168
+ The result notice is unstructured text; the interesting questions are aggregate. That is an
169
+ awkward fit for an agent working notice-by-notice, and a natural fit for a tool: `--json`
170
+ output with stable fields, an exit code that means something, and a firm-name match that
171
+ folds spelling variants so an agent doesn't have to.
172
+
173
+ There is a native **MCP server** (`pip install 'ihalent[mcp]'`) that exposes the analytics
174
+ as tools — `overview`, `firm`, `discounts`, `parse_notice`, `ingest_bundle` — over a dataset
175
+ you point it at:
176
+
177
+ ```
178
+ IHALENT_AWARDS=awards.jsonl ihalent-mcp
179
+ ```
180
+
181
+ Pair it with ihale-mcp and an agent can collect notices and reason across them in one
182
+ session: ihale-mcp fetches, ihalent structures and aggregates. The Python API
183
+ (`ihalent.ingest_bundle`, `ihalent.analytics.firm_profile`) is three calls deep if you'd
184
+ rather script it.
185
+
186
+ ## Scope, honestly
187
+
188
+ - **This is analytics, not a scraper.** Collection is ihale-mcp's job (it does it well);
189
+ ihalent deliberately owns the layer above and stays a pure function of the data you give
190
+ it — no network, no signing keys, nothing that breaks when EKAP rotates a header.
191
+ - **Company-name folding is conservative on purpose.** It merges legal-form suffixes and
192
+ Turkish spelling variants, and it would rather show two rows for one firm than one row for
193
+ two — so `firm` reports how many distinct spellings a query matched, and warns you if that
194
+ is more than one.
195
+ - **The parser tracks one document: the result notice.** Bid-level detail (who else bid, at
196
+ what price) is not in the notice and so is not here. Discount is the estimate-to-contract
197
+ gap, which the notice does carry.
198
+ - **Award values are nominal lira, as printed.** Comparing 2023 and 2026 contracts is your
199
+ analysis to make with the dates in hand; ihalent does not silently inflation-adjust.
200
+
201
+ ## What ihalent is not
202
+
203
+ It is not a replacement for [ihale-mcp](https://github.com/saidsurucu/ihale-mcp) — it sits
204
+ on top of it. It is not a live dashboard or a paid tender-alert service; it is a library and
205
+ a CLI you point at data you control. And it does not redistribute a dataset: it ships the
206
+ handful of real notices in `examples/` and the code to structure your own.
207
+
208
+ ## A note on the data
209
+
210
+ Public-tender results are public information, published by the state for public scrutiny.
211
+ ihalent structures what EKAP already discloses. Company names that appear are legal entities
212
+ in their public commercial capacity, not private individuals.
213
+
214
+ ## How this project is built
215
+
216
+ I designed the model and the analytics and I review every line; I use AI agents (Claude
217
+ Code) heavily for implementation speed, and the commit trailers say so. The contract is the
218
+ tests — 44 of them, built on the exact JSON shapes EKAP and ihale-mcp emit, including a
219
+ result notice with a negative discount and a cancelled tender. They don't care who typed
220
+ them.
221
+
222
+ ## License
223
+
224
+ [MIT](LICENSE) — Mehmet Eren Gülmez
@@ -0,0 +1,191 @@
1
+ # ihalent
2
+
3
+ **Turkish public-tender award intelligence: who won, for how much, at what discount, against how many bidders.**
4
+
5
+ [![CI](https://github.com/gulmezeren2-byte/ihalent/actions/workflows/ci.yml/badge.svg)](https://github.com/gulmezeren2-byte/ihalent/actions/workflows/ci.yml)
6
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8
+
9
+ Every public tender in Turkey ends with a *Sonuç İlanı* — a result notice that states the
10
+ estimate, the contract price, the winner, and how many firms bid. The notices are public.
11
+ They are also one at a time, unstructured, and impossible to reason across: you cannot ask
12
+ EKAP "what has this company won in the last two years" or "how far below estimate does this
13
+ authority actually award." ihalent turns those notices into structured records and answers
14
+ exactly those questions — firm histories, discount (kırım) distributions, and competition
15
+ metrics — with every number traceable back to the notice it came from.
16
+
17
+ ```
18
+ $ ihalent overview awards.jsonl
19
+
20
+ Dataset overview
21
+ 4 tenders: 4 awarded, 0 cancelled
22
+ total awarded value: 677.878.066,48 TL
23
+ Discount (kırım): mean 18.22% median 20.99% min -2.96% max 33.87%
24
+ based on all 4 awards.
25
+ competition: 5.75 valid bids on average (median 4.0); 0.0% had a single valid bid
26
+ data gaps: 0 awards without an estimate, 0 without a bid count.
27
+ ```
28
+
29
+ That is real output over four real December-2025 awards (in
30
+ [`examples/`](examples/)). The `-2.96%` is not a bug: Istanbul University awarded a
31
+ 615-million-lira campus job at **2.96% above** its own estimate, through an emergency
32
+ "pazarlık" procedure — a contract signed over the public estimate, which is precisely the
33
+ kind of thing that should be easy to see and currently is not.
34
+
35
+ ## Why this exists
36
+
37
+ I'm an industrial engineer and I work in construction. When our firm weighs a public
38
+ tender, the questions that decide the bid are all about other people's history: how far
39
+ below estimate does this authority award, how many firms usually show up, what has a given
40
+ competitor been winning and at what price. All of that is public — it is sitting in tens of
41
+ thousands of result notices on EKAP — and none of it is queryable. So everyone rebuilds a
42
+ private, partial version of it by hand, in spreadsheets, badly.
43
+
44
+ There is already an excellent tool for *reaching* this data:
45
+ [saidsurucu/ihale-mcp](https://github.com/saidsurucu/ihale-mcp) solved authenticated EKAP
46
+ access and hands you the notices. ihalent is the layer above it — the one that turns a pile
47
+ of notices into an answer. It does not re-scrape EKAP; it structures what you have collected
48
+ and does the analytics that don't exist yet.
49
+
50
+ ## What it answers
51
+
52
+ **A firm's history** — every award across the dataset, folded across spelling variants:
53
+
54
+ ```
55
+ $ ihalent firm awards.jsonl "ÖZDEN YEL"
56
+
57
+ ÖZDEN YEL
58
+ wins: 1 joint ventures: 1 total contract value: 19.709.997,40 TL
59
+ Discount (kırım): mean 33.87%
60
+ top awarding authorities:
61
+ 1x DSİ 14. Bölge Müdürlüğü
62
+ ```
63
+
64
+ **Where the discounts are** — grouped and sorted, highest first:
65
+
66
+ ```
67
+ $ ihalent discounts awards.jsonl --by authority
68
+
69
+ Mean discount by authority
70
+ DSİ 14. Bölge Müdürlüğü 33.87% 1/1
71
+ Esenler Belediyesi 21.99% 1/1
72
+ Ağrı İl Özel İdaresi 19.99% 1/1
73
+ İstanbul Üniversitesi-Cerrahpaşa -2.96% 1/1
74
+ ```
75
+
76
+ **Competition** — how many valid bids show up, and how often exactly one does (a single-bid
77
+ award is a flag procurement watchdogs care about). It's in the overview above.
78
+
79
+ ## The one rule
80
+
81
+ **A number is never shown without the ground it stands on.** A mean discount always comes
82
+ with "over how many awards, and how many were dropped for a missing estimate." A firm's
83
+ total contract value says so when some of its wins had no published price, so you read it as
84
+ a floor, not the full figure. A discount is `None`, never `0`, when the estimate wasn't
85
+ published — ihalent does not invent the numbers the government didn't print. Half the value
86
+ of a tool like this is refusing to guess.
87
+
88
+ ## Install
89
+
90
+ ```
91
+ pip install git+https://github.com/gulmezeren2-byte/ihalent
92
+ ```
93
+
94
+ A PyPI release (`pip install ihalent`) follows once the first users have kicked the tires.
95
+
96
+ ## The workflow
97
+
98
+ ihalent reads a JSONL file of awards. You produce it by collecting result notices — the
99
+ easy path is ihale-mcp — and letting ihalent structure them:
100
+
101
+ ```
102
+ # 1. collect: with ihale-mcp connected to your agent, save what its
103
+ # get_tender_announcements returns (one or many tenders) to bundle.json
104
+
105
+ # 2. structure:
106
+ ihalent ingest bundle.json -o awards.jsonl
107
+
108
+ # 3. ask:
109
+ ihalent overview awards.jsonl
110
+ ihalent firm awards.jsonl "ACME İNŞAAT"
111
+ ihalent discounts awards.jsonl --by province --min 5
112
+ ```
113
+
114
+ Every command takes `--json` for pipelines and agents. Or skip collection and try the
115
+ bundled example directly:
116
+
117
+ ```
118
+ git clone https://github.com/gulmezeren2-byte/ihalent && cd ihalent
119
+ python examples/build_sample.py # parses the real notices in examples/notices/
120
+ ihalent overview examples/sample-awards.jsonl
121
+ ```
122
+
123
+ ## Commands
124
+
125
+ | command | what it does |
126
+ |---|---|
127
+ | `ihalent overview AWARDS` | value, discount, competition and the data gaps of a dataset |
128
+ | `ihalent firm AWARDS "NAME"` | one company's wins, total value, discount, and where it wins |
129
+ | `ihalent discounts AWARDS --by X` | mean/median discount grouped by authority, province or tender_type |
130
+ | `ihalent parse NOTICE.md` | one result notice → structured JSON |
131
+ | `ihalent ingest BUNDLE.json` | collected ihale-mcp/EKAP output → awards JSONL |
132
+
133
+ ## Using it with AI agents
134
+
135
+ The result notice is unstructured text; the interesting questions are aggregate. That is an
136
+ awkward fit for an agent working notice-by-notice, and a natural fit for a tool: `--json`
137
+ output with stable fields, an exit code that means something, and a firm-name match that
138
+ folds spelling variants so an agent doesn't have to.
139
+
140
+ There is a native **MCP server** (`pip install 'ihalent[mcp]'`) that exposes the analytics
141
+ as tools — `overview`, `firm`, `discounts`, `parse_notice`, `ingest_bundle` — over a dataset
142
+ you point it at:
143
+
144
+ ```
145
+ IHALENT_AWARDS=awards.jsonl ihalent-mcp
146
+ ```
147
+
148
+ Pair it with ihale-mcp and an agent can collect notices and reason across them in one
149
+ session: ihale-mcp fetches, ihalent structures and aggregates. The Python API
150
+ (`ihalent.ingest_bundle`, `ihalent.analytics.firm_profile`) is three calls deep if you'd
151
+ rather script it.
152
+
153
+ ## Scope, honestly
154
+
155
+ - **This is analytics, not a scraper.** Collection is ihale-mcp's job (it does it well);
156
+ ihalent deliberately owns the layer above and stays a pure function of the data you give
157
+ it — no network, no signing keys, nothing that breaks when EKAP rotates a header.
158
+ - **Company-name folding is conservative on purpose.** It merges legal-form suffixes and
159
+ Turkish spelling variants, and it would rather show two rows for one firm than one row for
160
+ two — so `firm` reports how many distinct spellings a query matched, and warns you if that
161
+ is more than one.
162
+ - **The parser tracks one document: the result notice.** Bid-level detail (who else bid, at
163
+ what price) is not in the notice and so is not here. Discount is the estimate-to-contract
164
+ gap, which the notice does carry.
165
+ - **Award values are nominal lira, as printed.** Comparing 2023 and 2026 contracts is your
166
+ analysis to make with the dates in hand; ihalent does not silently inflation-adjust.
167
+
168
+ ## What ihalent is not
169
+
170
+ It is not a replacement for [ihale-mcp](https://github.com/saidsurucu/ihale-mcp) — it sits
171
+ on top of it. It is not a live dashboard or a paid tender-alert service; it is a library and
172
+ a CLI you point at data you control. And it does not redistribute a dataset: it ships the
173
+ handful of real notices in `examples/` and the code to structure your own.
174
+
175
+ ## A note on the data
176
+
177
+ Public-tender results are public information, published by the state for public scrutiny.
178
+ ihalent structures what EKAP already discloses. Company names that appear are legal entities
179
+ in their public commercial capacity, not private individuals.
180
+
181
+ ## How this project is built
182
+
183
+ I designed the model and the analytics and I review every line; I use AI agents (Claude
184
+ Code) heavily for implementation speed, and the commit trailers say so. The contract is the
185
+ tests — 44 of them, built on the exact JSON shapes EKAP and ihale-mcp emit, including a
186
+ result notice with a negative discount and a cancelled tender. They don't care who typed
187
+ them.
188
+
189
+ ## License
190
+
191
+ [MIT](LICENSE) — Mehmet Eren Gülmez
@@ -0,0 +1,64 @@
1
+ # Data model and JSON schema
2
+
3
+ ihalent's interchange format is JSON Lines: one award per line. This page documents the
4
+ `Award` record so you can produce it from any source, not only ihale-mcp.
5
+
6
+ ## Award
7
+
8
+ | field | type | meaning |
9
+ |---|---|---|
10
+ | `ikn` | string | İhale Kayıt Numarası, e.g. `"2025/2198370"`. The primary key. |
11
+ | `title` | string \| null | Tender title. |
12
+ | `authority` | string \| null | Awarding authority (idare). |
13
+ | `province` | string \| null | Province. |
14
+ | `tender_type` | string | `mal` \| `yapim` \| `hizmet` \| `danismanlik` \| `unknown`. |
15
+ | `estimate_try` | number \| null | Yaklaşık Maliyeti (public estimate), nominal lira. |
16
+ | `contract_try` | number \| null | Sözleşme Bedeli (contract price), nominal lira. |
17
+ | `winner` | string \| null | Lead bidder (first named party in a joint venture). |
18
+ | `winners_all` | string[] | All named parties (the JV members). |
19
+ | `is_joint_venture` | bool | True if the award went to an İş Ortaklığı. |
20
+ | `downloaders` | int \| null | Firms that downloaded the documents. |
21
+ | `bid_count` | int \| null | Toplam Teklif Sayısı. |
22
+ | `valid_bid_count` | int \| null | Toplam Geçerli Teklif Sayısı. |
23
+ | `result_date` | string \| null | Contract date, ISO (`YYYY-MM-DD`) where parseable. |
24
+ | `cancelled` | bool | True for a cancelled tender (result notice with no winner/price). |
25
+
26
+ Two fields are **computed** and appear in serialized output but are never stored as input:
27
+
28
+ | field | type | meaning |
29
+ |---|---|---|
30
+ | `discount_pct` | number \| null | `100 * (estimate - contract) / estimate`, rounded to 2 dp. `null` if either value is missing or the estimate is 0. Negative means the contract came in *above* estimate. |
31
+ | `single_bid` | bool \| null | `valid_bid_count <= 1`. `null` if the count is missing. |
32
+
33
+ ### The null discipline
34
+
35
+ Every optional field is `null` when the source did not carry it — never `0`, never `""`.
36
+ A discount computed from a missing estimate is `null`, not `0%`. This is load-bearing: the
37
+ analytics layer counts these gaps and reports them (`data_gaps` in the overview, the
38
+ `coverage` block on every statistic), so a mean is always qualified by how many records
39
+ actually had the number.
40
+
41
+ ## Example line
42
+
43
+ ```json
44
+ {"ikn": "2025/2198370", "title": "İSTANBUL ARNAVUTKÖY TÜRKKÖŞE DERESİ ISLAHI", "authority": "DSİ 14. Bölge Müdürlüğü", "province": "İstanbul", "tender_type": "yapim", "estimate_try": 29802943.48, "contract_try": 19709997.4, "winner": "ÖZDEN YEL", "winners_all": ["ÖZDEN YEL", "ATAŞ MÜHENDİSLİK ..."], "is_joint_venture": true, "downloaders": 34, "bid_count": 20, "valid_bid_count": 12, "result_date": "2026-02-13", "cancelled": false, "discount_pct": 33.87, "single_bid": false}
45
+ ```
46
+
47
+ ## Producing awards
48
+
49
+ Three routes, all landing on the same JSONL:
50
+
51
+ 1. **From ihale-mcp / EKAP** — `ihalent ingest bundle.json` (or `ihalent.ingest_bundle` in
52
+ Python). Accepts a `get_tender_announcements` object, a list of them, or a list of
53
+ `{tender, announcements}` pairs.
54
+ 2. **From a single notice** — `ihalent parse notice.md` emits one record you can append.
55
+ 3. **From your own pipeline** — build `Award` objects directly and `dump_awards(...)`; only
56
+ `ikn` is required.
57
+
58
+ ## Company-name folding
59
+
60
+ Firm queries and JV membership are matched on a normalized key: Turkish diacritics folded to
61
+ ASCII, legal-form suffixes (`LİMİTED ŞİRKETİ`, `LTD ŞTİ`, `SANAYİ VE TİCARET ...`) stripped,
62
+ whitespace and punctuation collapsed. The fold is deliberately conservative — it will not
63
+ merge `ANADOLU İNŞAAT` with `ANKARA İNŞAAT` — and `firm` reports `distinct_spellings` so an
64
+ over-broad query is visible rather than silently merging unrelated companies.
@@ -0,0 +1,25 @@
1
+ # Example: four real December-2025 awards
2
+
3
+ The `notices/` folder holds four real result notices (Sonuç İlanı), pulled from EKAP via
4
+ ihale-mcp, one per file, named by İKN. They span four kinds of awarding authority on
5
+ purpose:
6
+
7
+ | İKN | authority | discount | note |
8
+ |---|---|---|---|
9
+ | 2025/2337797 | Ağrı İl Özel İdaresi | 19.99% | joint venture, provincial repair job |
10
+ | 2025/2198370 | DSİ 14. Bölge Müdürlüğü | 33.87% | 12 valid bids — real competition |
11
+ | 2025/2174700 | Esenler Belediyesi | 21.99% | municipal school/mosque repairs |
12
+ | 2025/2388267 | İstanbul Üniversitesi-Cerrahpaşa | **-2.96%** | emergency "pazarlık", awarded *above* estimate |
13
+
14
+ We ship the notices, not a redistributed dataset. `build_sample.py` parses them into
15
+ `sample-awards.jsonl` exactly as `ihalent ingest` would with your own collected notices:
16
+
17
+ ```
18
+ python build_sample.py
19
+ ihalent overview sample-awards.jsonl
20
+ ihalent discounts sample-awards.jsonl --by authority
21
+ ```
22
+
23
+ The `-2.96%` case is the one to notice. A contract signed 2.96% above the public estimate,
24
+ through an emergency procedure that skips open bidding, is exactly the kind of thing these
25
+ notices disclose and nobody aggregates — which is the whole reason this tool exists.