Skip to content

Commit d315d80

Browse files
committed
Phase K: Add search analytics and admin ranking controls
Introduce search analytics and admin-tunable ranking weights. Added RankingSettings, RankingWeight and multiple Search analytics DTOs under app/Search; repositories RankingSettingsRepository and SearchAnalyticsRepository under app/Repository; migration database/migrations/005_create_search_analytics.sql and updated doogle-tables-no-data.sql. Public changes: new admin page public/ranking.php, record searches and load ranking overrides in public/search.php, minor crawl UI link and CSS for admin UI. Repositories (Site/Image/Video) now read weights from RankingSettings and apply full-text caps from settings. Tests and docs updated (ARCHITECTURE2.md, README-refactored.md, new integration/unit tests) and analytics store only hashed IP/user-agent to protect raw identifiers. Searches continue to work if DB migration is not applied; failures fall back to defaults.
1 parent 3da5bbe commit d315d80

23 files changed

Lines changed: 1537 additions & 52 deletions

ARCHITECTURE2.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,19 @@ The next target layout is:
108108
│ ├── Repository/
109109
│ │ ├── ImageRepository.php
110110
│ │ ├── ImageSearchRepository.php
111+
│ │ ├── RankingSettingsRepository.php
112+
│ │ ├── SearchAnalyticsRepository.php
111113
│ │ ├── SiteRepository.php
112114
│ │ ├── SiteSearchRepository.php
113115
│ │ ├── VideoRepository.php
114116
│ │ └── VideoSearchRepository.php
115117
│ ├── Search/
118+
│ │ ├── RankingExpression.php
119+
│ │ ├── RankingSettings.php
120+
│ │ ├── RankingWeight.php
121+
│ │ ├── SearchAnalyticsEvent.php
122+
│ │ ├── SearchAnalyticsTerm.php
123+
│ │ └── SearchAnalyticsTypeSummary.php
116124
│ └── Security/
117125
│ ├── CrawlerSecurityPolicy.php
118126
│ ├── CsrfToken.php
@@ -140,6 +148,7 @@ The next target layout is:
140148
│ ├── login.php
141149
│ ├── logout.php
142150
│ ├── crawl.php
151+
│ ├── ranking.php
143152
│ ├── ajax/
144153
│ │ ├── setBroken.php
145154
│ │ ├── updateImageCount.php
@@ -189,6 +198,7 @@ public/
189198
├── login.php
190199
├── logout.php
191200
├── crawl.php
201+
├── ranking.php
192202
├── ajax/
193203
│ ├── setBroken.php
194204
│ ├── updateImageCount.php
@@ -1229,11 +1239,6 @@ Ranking by result type:
12291239
- Images prioritise exact/partial title, then alt text, image URL, HTTPS, and available title/alt text.
12301240
- Videos prioritise exact/partial title, then description, video URL, site URL, HTTPS, thumbnail availability, and metadata completeness.
12311241

1232-
Future optional work:
1233-
1234-
- Search analytics beyond click telemetry.
1235-
- Authenticated admin ranking settings for viewing/tuning ranking weights.
1236-
12371242
Acceptance criteria:
12381243

12391244
- all search verticals use the same ranking model shape
@@ -1242,6 +1247,30 @@ Acceptance criteria:
12421247
- ranking order is deterministic
12431248
- repository tests cover ranking behaviour
12441249

1250+
### Phase K - Search Analytics And Admin Ranking Controls
1251+
1252+
Goal: make search quality observable and allow authenticated admins to tune the
1253+
existing ranking weights without changing the default ranking behaviour.
1254+
1255+
Tasks:
1256+
1257+
- add `search_queries` table and migration
1258+
- record public site/image/video searches with term, vertical, page, result count, and hashed request identifiers
1259+
- add `ranking_settings` table and migration
1260+
- load ranking weight overrides from the database with safe defaults when the migration is absent
1261+
- add admin-only `/ranking.php`
1262+
- show search analytics by vertical, top terms, zero-result terms, and recent searches
1263+
- allow admins to update per-vertical ranking weights with CSRF protection
1264+
- log ranking update/security events without secrets
1265+
1266+
Acceptance criteria:
1267+
1268+
- public search continues working if analytics/ranking tables are not migrated yet
1269+
- default ranking weights match Phase J
1270+
- saved ranking weights affect subsequent search results
1271+
- only authenticated admins can view or update ranking controls
1272+
- tests cover analytics persistence, ranking settings persistence, DTOs, and ranking behaviour
1273+
12451274
---
12461275

12471276
## 20. Security Requirements
@@ -1258,7 +1287,7 @@ Acceptance criteria:
12581287
- Keep crawler security policy enabled by default.
12591288
- Apply baseline security headers at Nginx.
12601289
- Rate limit login and crawl POST requests.
1261-
- Log auth, crawl, CSRF, and rate-limit events without secrets.
1290+
- Log auth, crawl, ranking, CSRF, and rate-limit events without secrets.
12621291

12631292
### 20.2 Session Cookie Settings
12641293

README-refactored.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@ Completed from `ARCHITECTURE2.md`:
2424
- Phase H: Production Hardening
2525
- Phase I: Videos Search Vertical
2626
- Phase J: Search Ranking
27-
28-
Remaining:
29-
30-
- Phase K: Search analytics and admin ranking controls are future optional work.
27+
- Phase K: Search Analytics And Admin Ranking Controls
3128

3229
Key changes now in place:
3330

@@ -37,6 +34,8 @@ Key changes now in place:
3734
- Legacy root `crawl.php`, root `crawl-manual.php`, and `classes/` have been removed.
3835
- Search uses repositories, services, DTOs, full-text indexes, relevance ranking, and bounded click boost for sites, images, and videos.
3936
- Ranking uses a shared deterministic formula with capped click/full-text boosts and per-vertical quality signals.
37+
- Public searches are recorded in `search_queries` with hashed request identifiers for admin analytics.
38+
- Admins can view analytics and tune per-vertical ranking weights at `/ranking.php`.
4039
- Browser crawling requires an admin login and CSRF token.
4140
- CLI crawling does not require a browser session, but still enforces crawler safety policy.
4241
- Web and CLI crawl jobs are stored in `crawl_jobs` and shown on the authenticated crawl page.
@@ -70,6 +69,7 @@ Open:
7069

7170
- Doogle search: http://localhost:8000
7271
- Admin crawl page: http://localhost:8000/crawl.php
72+
- Admin ranking page: http://localhost:8000/ranking.php
7373
- phpMyAdmin: http://localhost:8081
7474

7575
Default local database details without a `.env` override:
@@ -138,6 +138,26 @@ Site search returns 20 results per page. Image search returns 30 results per
138138
page. Video search returns 24 results per page in a responsive thumbnail-card
139139
grid.
140140

141+
Search analytics are recorded when `database/migrations/005_create_search_analytics.sql`
142+
has been applied. If the analytics table is not present, public search still
143+
renders normally.
144+
145+
## Admin Ranking And Analytics
146+
147+
The admin ranking page is available after login:
148+
149+
```text
150+
http://localhost:8000/ranking.php
151+
```
152+
153+
It shows search analytics by vertical, top terms, zero-result terms, and recent
154+
searches. Request IP and user-agent values are stored as SHA-256 hashes, not as
155+
raw values.
156+
157+
The same page lets admins update per-vertical ranking weights for Sites, Images,
158+
and Videos. Default weights match Phase J; saved overrides are loaded by public
159+
search on the next request. Click boost and full-text boost caps remain fixed.
160+
141161
## Crawling: Web UI
142162

143163
Use the web UI when you want an authenticated browser workflow and crawl
@@ -266,13 +286,15 @@ database/migrations/001_add_search_fulltext_indexes.sql
266286
database/migrations/002_update_users_auth_schema.sql
267287
database/migrations/003_create_crawl_jobs.sql
268288
database/migrations/004_add_video_search.sql
289+
database/migrations/005_create_search_analytics.sql
269290
```
270291

271292
Apply a migration to the Docker database:
272293

273294
```sh
274295
docker compose -f docker/compose.yml exec -T mysql_db mysql -uroot -proot doogle < database/migrations/003_create_crawl_jobs.sql
275296
docker compose -f docker/compose.yml exec -T mysql_db mysql -uroot -proot doogle < database/migrations/004_add_video_search.sql
297+
docker compose -f docker/compose.yml exec -T mysql_db mysql -uroot -proot doogle < database/migrations/005_create_search_analytics.sql
276298
```
277299

278300
For non-Docker MySQL, apply the same SQL files with your normal MySQL client.
@@ -375,13 +397,14 @@ If you already have an older Docker volume or database:
375397

376398
Resetting the Docker database deletes local indexed sites, images, videos,
377399
users, and crawl history.
400+
It also deletes search analytics and ranking setting overrides.
378401

379402
## Security Defaults
380403

381404
- Browser crawl requires an authenticated admin.
382405
- Browser crawl POST requires CSRF validation.
383406
- Login and crawl POST requests are rate limited.
384-
- Auth and crawl security events are logged.
407+
- Auth, crawl, and ranking security events are logged.
385408
- CLI crawl is trusted local/container execution, not public HTTP access.
386409
- Private/reserved network crawling is blocked by default.
387410
- Passwords are hashed with `password_hash()`.

app/Repository/ImageRepository.php

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
namespace Doogle\Repository;
66

77
use Doogle\Search\RankingExpression;
8+
use Doogle\Search\RankingSettings;
89
use PDO;
910
use PDOStatement;
1011

1112
final class ImageRepository implements ImageSearchRepository
1213
{
1314
private const IMAGE_FULL_TEXT_COLUMNS = 'title, alt, imageUrl';
15+
private readonly RankingSettings $rankingSettings;
1416

15-
public function __construct(private readonly PDO $pdo)
17+
public function __construct(private readonly PDO $pdo, ?RankingSettings $rankingSettings = null)
1618
{
19+
$this->rankingSettings = $rankingSettings ?? new RankingSettings();
1720
}
1821

1922
public function countBySearchTerm(string $term): int
@@ -92,15 +95,16 @@ private function imageWhereSql(): string
9295

9396
private function imageRankingSql(): string
9497
{
98+
$weights = $this->rankingSettings->weightsFor('images');
9599
$scores = [
96-
RankingExpression::weightedEquals('title', ':rankTitleExactTerm', 220),
97-
RankingExpression::weightedEquals('alt', ':rankAltExactTerm', 180),
98-
RankingExpression::weightedLike('title', ':rankTitleTerm', 100),
99-
RankingExpression::weightedLike('alt', ':rankAltTerm', 75),
100-
RankingExpression::weightedLike('imageUrl', ':rankImageUrlTerm', 25),
101-
RankingExpression::httpsUrl('imageUrl', 3),
102-
RankingExpression::nonEmpty('alt', 5),
103-
RankingExpression::nonEmpty('title', 3),
100+
RankingExpression::weightedEquals('title', ':rankTitleExactTerm', $weights['title_exact']),
101+
RankingExpression::weightedEquals('alt', ':rankAltExactTerm', $weights['alt_exact']),
102+
RankingExpression::weightedLike('title', ':rankTitleTerm', $weights['title_partial']),
103+
RankingExpression::weightedLike('alt', ':rankAltTerm', $weights['alt_partial']),
104+
RankingExpression::weightedLike('imageUrl', ':rankImageUrlTerm', $weights['image_url_partial']),
105+
RankingExpression::httpsUrl('imageUrl', $weights['https_image_url']),
106+
RankingExpression::nonEmpty('alt', $weights['alt_present']),
107+
RankingExpression::nonEmpty('title', $weights['title_present']),
104108
RankingExpression::boundedClickBoost(),
105109
];
106110

@@ -110,8 +114,8 @@ private function imageRankingSql(): string
110114
RankingExpression::boundedMysqlFullTextBoost(
111115
self::IMAGE_FULL_TEXT_COLUMNS,
112116
':rankFullTextTerm',
113-
50,
114-
120
117+
$this->rankingSettings->fullTextWeight(),
118+
$this->rankingSettings->fullTextCap()
115119
)
116120
);
117121
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doogle\Repository;
6+
7+
use Doogle\Search\RankingSettings;
8+
use PDO;
9+
10+
final class RankingSettingsRepository
11+
{
12+
public function __construct(private readonly PDO $pdo)
13+
{
14+
}
15+
16+
public function load(): RankingSettings
17+
{
18+
$statement = $this->pdo->query(
19+
'SELECT setting_key, setting_value
20+
FROM ranking_settings'
21+
);
22+
23+
if ($statement === false) {
24+
return new RankingSettings();
25+
}
26+
27+
$overrides = [];
28+
29+
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
30+
$key = (string) ($row['setting_key'] ?? '');
31+
32+
if ($key === '') {
33+
continue;
34+
}
35+
36+
$overrides[$key] = (int) ($row['setting_value'] ?? 0);
37+
}
38+
39+
return new RankingSettings($overrides);
40+
}
41+
42+
/**
43+
* @param array<string, int> $weights
44+
*/
45+
public function saveWeights(string $type, array $weights): void
46+
{
47+
$settings = new RankingSettings();
48+
$type = in_array($type, $settings->supportedTypes(), true) ? $type : 'sites';
49+
$sanitizedWeights = $settings->sanitizeWeights($type, $weights);
50+
51+
foreach ($sanitizedWeights as $key => $value) {
52+
$this->upsertSetting(RankingSettings::settingKey($type, $key), (string) $value);
53+
}
54+
}
55+
56+
private function upsertSetting(string $key, string $value): void
57+
{
58+
if ($this->isMysql()) {
59+
$statement = $this->pdo->prepare(
60+
'INSERT INTO ranking_settings (setting_key, setting_value)
61+
VALUES (:setting_key, :setting_value)
62+
ON DUPLICATE KEY UPDATE
63+
setting_value = VALUES(setting_value),
64+
updated_at = CURRENT_TIMESTAMP'
65+
);
66+
} else {
67+
$statement = $this->pdo->prepare(
68+
'INSERT OR REPLACE INTO ranking_settings (setting_key, setting_value, updated_at)
69+
VALUES (:setting_key, :setting_value, CURRENT_TIMESTAMP)'
70+
);
71+
}
72+
73+
$statement->execute([
74+
':setting_key' => $key,
75+
':setting_value' => $value,
76+
]);
77+
}
78+
79+
private function isMysql(): bool
80+
{
81+
return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql';
82+
}
83+
}

0 commit comments

Comments
 (0)