mirror of
https://github.com/nubenetes/awesome-kubernetes.git
synced 2026-09-01 08:07:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01634bceb2 | ||
|
|
d456128366 | ||
|
|
13d51e8fa9 | ||
|
|
87809870c5 | ||
|
|
e1b49ab4b5 | ||
|
|
57f9c618f1 | ||
|
|
65ce209183 | ||
|
|
fdd9eac809 | ||
|
|
059b4de3e8 | ||
|
|
a11bfe4ced | ||
|
|
9cb73cadcc | ||
|
|
05b66d006f | ||
|
|
3dc52af391 | ||
|
|
8b04851097 | ||
|
|
782dc1bd06 | ||
|
|
1149a9c8f7 | ||
|
|
4e87c248fd | ||
|
|
51e8ec8cfa | ||
|
|
138b63c69f | ||
|
|
95c7078e6a | ||
|
|
cba67c689c | ||
|
|
e527be5189 | ||
|
|
1cd243ee58 | ||
|
|
0c3ae9661c | ||
|
|
0f53474aa9 | ||
|
|
12402e089d | ||
|
|
871bd30e75 | ||
|
|
78f5600e75 | ||
|
|
d7c9162cb8 | ||
|
|
24b5bbc6a2 | ||
|
|
42442a1a6c | ||
|
|
5fe5431c53 |
@@ -178,6 +178,21 @@ jobs:
|
||||
gh workflow run 01.1.agentic_cron.yml -f historical_mode=true -f historical_chunked=true -f historical_until_date=$NEXT_DATE
|
||||
fi
|
||||
|
||||
- name: Run Deduplication Scan
|
||||
if: success()
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
python -u -c "import asyncio; from src.dedup import run_dedup; asyncio.run(run_dedup(dry_run=False))" || echo "Dedup scan skipped"
|
||||
|
||||
- name: Run Enrichment Pipeline
|
||||
if: success()
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -u -m src.enrichment || echo "Enrichment pipeline skipped"
|
||||
|
||||
- name: Upload Visual Dashboard Artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -61,9 +61,9 @@ jobs:
|
||||
run: |
|
||||
git config --global user.name "Nubenetes Bot"
|
||||
git config --global user.email "bot@nubenetes.com"
|
||||
git add data/inventory.yaml
|
||||
git add data/inventory.yaml data/inventory.sql
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes in inventory.yaml to commit."
|
||||
echo "No changes in inventory to commit."
|
||||
else
|
||||
git commit -m "chore: update inventory stars and licenses [skip ci]"
|
||||
git pull origin develop --rebase
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
run: |
|
||||
git config --global user.name "Nubenetes Bot"
|
||||
git config --global user.email "bot@nubenetes.com"
|
||||
git add data/inventory.yaml
|
||||
git add data/inventory.yaml data/inventory.sql
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes in AI analysis to commit."
|
||||
else
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
git config --global user.name "Nubenetes Bot"
|
||||
git config --global user.email "bot@nubenetes.com"
|
||||
git add data/inventory.yaml v2-docs/videos/
|
||||
git add data/inventory.yaml data/inventory.sql v2-docs/videos/
|
||||
if git diff --staged --quiet; then
|
||||
echo "No automated changes to commit."
|
||||
else
|
||||
|
||||
@@ -73,6 +73,27 @@ jobs:
|
||||
run: |
|
||||
python src/reorganize_mosaic.py
|
||||
|
||||
- name: Run Deduplication Scan
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
python -u -c "import asyncio; from src.dedup import run_dedup; asyncio.run(run_dedup(dry_run=False))" || echo "Dedup scan skipped"
|
||||
|
||||
- name: Run Enrichment Pipeline
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -u -m src.enrichment || echo "Enrichment pipeline skipped (no token or error)"
|
||||
|
||||
- name: Generate News Digest
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
GEMINI_API_KEY_1: ${{ secrets.GEMINI_API_KEY_1 }}
|
||||
GEMINI_API_KEY_2: ${{ secrets.GEMINI_API_KEY_2 }}
|
||||
run: |
|
||||
python -u -m src.news_digest || echo "News digest generation skipped (no API key or error)"
|
||||
|
||||
- name: Run V2 Publisher (Render-Only)
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
run: |
|
||||
git config --global user.name "nubenetes-bot"
|
||||
git config --global user.email "bot@nubenetes.com"
|
||||
git add docs/ v2-docs/ README.md data/inventory.yaml src/memory/
|
||||
git add docs/ v2-docs/ README.md data/inventory.yaml data/inventory.sql src/memory/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "style(cleaner): auto-correcting formatting & URL normalization"
|
||||
git push origin HEAD:${{ github.event.pull_request.head.ref }} || echo "⚠️ Push failed (likely fork permission limit)."
|
||||
|
||||
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [[2.5.8]](https://github.com/nubenetes/awesome-kubernetes/releases/tag/v2.5.8) - 2026-06-18
|
||||
|
||||
### Added
|
||||
- **Debate & Ingestion Documentation**: Documented the Fast-Pass screening evaluator, Twikit toggle, failure-aware domain timeouts, mobile identity rotation, and tag pagination limits in `README.md`.
|
||||
- **Consensus Flow Visualization**: Redesigned the multi-agent consensus protocol Mermaid diagram in `README.md` to map the Fast-Pass screening logic.
|
||||
|
||||
## [[2.5.7]](https://github.com/nubenetes/awesome-kubernetes/releases/tag/v2.5.7) - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# CLAUDE.md — Nubenetes Project Instructions for Claude Code
|
||||
|
||||
## Git Workflow: Gitflow
|
||||
|
||||
This repository uses **Gitflow**. All agents MUST follow this branching model:
|
||||
|
||||
### Branches
|
||||
- **`master`** — Production. Only receives merges from release branches. Every merge to master gets a **tag** and a **GitHub Release**.
|
||||
- **`develop`** — Integration branch. All feature branches merge here first.
|
||||
- **`feat/*`** — Feature branches. Created from `develop`, merged back to `develop` via PR.
|
||||
- **`release/vX.Y.Z`** — Release branches. Created from `develop` when ready to release, merged to both `master` AND back to `develop`.
|
||||
- **`gh-pages`** — Deployment. Never touch directly.
|
||||
|
||||
### Release Process (mandatory for all releases)
|
||||
1. Create feature branch from `develop`: `git checkout -b feat/description develop`
|
||||
2. Implement changes, commit, push feature branch
|
||||
3. Create PR: `gh pr create --base develop --head feat/description`
|
||||
4. Merge PR to develop: `gh pr merge N --merge`
|
||||
5. Create release branch: `git checkout -b release/vX.Y.Z develop`
|
||||
6. Merge release to master: `git checkout master && git merge release/vX.Y.Z --no-ff`
|
||||
7. Tag: `git tag -a vX.Y.Z -m "description"`
|
||||
8. Push master + tag: `git push origin master && git push origin vX.Y.Z`
|
||||
9. Back-merge master to develop: `git checkout develop && git merge master --no-ff && git push origin develop`
|
||||
10. Create GitHub Release: `gh release create vX.Y.Z --title "..." --notes "..."`
|
||||
|
||||
### Versioning
|
||||
- Current: `v2.6.0`
|
||||
- Format: `v{major}.{minor}.{patch}`
|
||||
- Major: breaking changes or architectural shifts
|
||||
- Minor: new features (like the digest engine, new modules)
|
||||
- Patch: bug fixes, config tweaks
|
||||
|
||||
### Protected Branches
|
||||
`master`, `develop`, and `gh-pages` are NEVER deleted. Branch cleanup runs bi-monthly for merged feature branches.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
### Key Directories
|
||||
- `docs/` — V1 source (exhaustive archive, source of truth)
|
||||
- `v2-docs/` — V2 source (AI-curated elite portal, derived from V1)
|
||||
- `src/` — Python pipeline source code
|
||||
- `data/` — Inventory (YAML + SQL), config files, digest JSON
|
||||
- `scripts/` — Utility scripts (backfill, etc.)
|
||||
- `.github/workflows/` — CI/CD (15 workflows)
|
||||
|
||||
### Key Config Files
|
||||
- `v2-mkdocs.yml` — V2 MkDocs Material configuration
|
||||
- `mkdocs.yml` — V1 MkDocs configuration
|
||||
- `data/inventory.yaml` / `data/inventory.sql` — Unified inventory (18K+ entries)
|
||||
- `data/curation_sources.yaml` — RSS feeds and X/Twitter accounts
|
||||
- `data/link_rules.yaml` — Curation policies
|
||||
- `GEMINI.md` — AI mandates and learning roadmap (read by Gemini agents)
|
||||
|
||||
### Pipeline Modules
|
||||
- `src/v2_optimizer.py` — Main rendering engine (V2VisionEngine class)
|
||||
- `src/news_digest.py` — 26-category temporal digest with Gemini ranking
|
||||
- `src/enrichment.py` — CNCF Landscape + GitHub activity + license detection
|
||||
- `src/dedup.py` — URL/hash/title deduplication engine
|
||||
- `src/agentic_curator.py` — Ingestion pipeline with AI evaluation
|
||||
- `src/autonomous_discovery.py` — GitHub trending discovery (14 queries)
|
||||
- `src/gemini_utils.py` — Gemini API wrapper with key rotation
|
||||
- `src/inventory_manager.py` — Dual YAML+SQL inventory management
|
||||
|
||||
## Build and Run
|
||||
|
||||
### Local testing (no API keys needed)
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
mkdocs serve -f v2-mkdocs.yml # Preview V2 portal
|
||||
mkdocs serve -f mkdocs.yml # Preview V1 portal
|
||||
```
|
||||
|
||||
### Pipeline commands
|
||||
```bash
|
||||
python3 -m scripts.backfill_discovered_at # Backfill discovered_at field
|
||||
python3 -m src.news_digest # Generate digest (needs Gemini API key)
|
||||
python3 -m src.enrichment # CNCF + GitHub enrichment (needs GH_TOKEN)
|
||||
python3 -m src.dedup # Dedup scan (dry-run by default)
|
||||
python3 -m src.v2_optimizer --render-only # Render V2 portal (no AI calls)
|
||||
```
|
||||
|
||||
## Coding Conventions
|
||||
- All Python exceptions must use `except Exception as e: log_event(f"[WARN] context: {str(e)[:100]}")` — never bare `except: pass`
|
||||
- Use `from src.logger import log_event` for logging
|
||||
- Use `from src.config import MADRID_TZ` for timezone-aware timestamps
|
||||
- Inventory fields: `discovered_at` (ISO), `last_ai_eval` (ISO), `company`, `geo_region` must be preserved during merges
|
||||
- The `update_inventory_entry()` function preserves `discovered_at` — never overwrite it with new data
|
||||
|
||||
## URL Policy: Clean URLs, No .html Suffix
|
||||
Both V1 and V2 MUST use `use_directory_urls: true` in their mkdocs.yml. This produces clean URLs like `/kubernetes/` instead of `/kubernetes.html`. **NEVER** enable the `offline` plugin — it forces `.html` suffixes on all URLs, breaking SEO and existing deep-links. This is a hard rule.
|
||||
|
||||
## RSS/Twitter Sources
|
||||
RSS feeds are limited to those that actually work (many block bots). Don't add new RSS feeds without testing. Current working feeds are defined in `data/curation_sources.yaml`.
|
||||
@@ -376,4 +376,31 @@ The bot must rotate between profiles to avoid detection:
|
||||
- **V2 Index Metrics Protocol**: The "Knowledge Architecture and AI Coverage Status" report in the V2 index MUST include a direct comparison between V1 and V2 inventory. This report MUST display: 1. **V1 Base Inventory** (Total resources in the master archive), 2. **V2 Elite Selection** (Count of candidates and the resulting density ratio), 3. **AI Enrichment Coverage**, and 4. **GitHub Metadata Coverage**. This ensures transparency in the knowledge distillation process.
|
||||
- **Redundancy-Free Branding**: To ensure professional UI density, the V2 Portal header MUST NOT repeat the "Nubenetes" brand. The title MUST follow the pattern: "Nubenetes Elite Portal (V2) | Awesome Kubernetes and Cloud".
|
||||
- **Decoupled Workflow Architecture**: The Agentic V2 ecosystem MUST utilize a decoupled micro-workflow structure (Health Monitor, Metadata Engine, AI Curator, and Publisher) to optimize compute quotas and minimize Gemini token consumption. Any update to the V2 rendering logic MUST use the `--render-only` flag in the Publisher pipeline to maintain execution speed.
|
||||
to maintain execution speed.
|
||||
|
||||
## Git Workflow: Gitflow (Mandatory)
|
||||
|
||||
This repository uses **Gitflow**. All agents and automated processes MUST follow this branching model:
|
||||
|
||||
- **`master`**: Production branch. Only receives merges from `release/*` branches. Every merge to master gets a **semantic version tag** (`vX.Y.Z`) and a **GitHub Release** with detailed release notes.
|
||||
- **`develop`**: Integration branch. All feature branches merge here via PR. Back-merged from master after each release.
|
||||
- **`feat/*`**: Feature branches. Created from `develop`, merged back to `develop` via PR.
|
||||
- **`release/vX.Y.Z`**: Release branches. Created from `develop`, merged to `master` with `--no-ff`, then back-merged to `develop`.
|
||||
- **`gh-pages`**: Deployment. NEVER modified directly.
|
||||
- **Protected branches**: `master`, `develop`, `gh-pages` — NEVER deleted.
|
||||
|
||||
### Release Sequence
|
||||
1. Feature branch → PR to `develop` → merge
|
||||
2. Create `release/vX.Y.Z` from `develop`
|
||||
3. Merge release to `master` (`--no-ff`)
|
||||
4. Create annotated tag: `git tag -a vX.Y.Z -m "..."`
|
||||
5. Push master + tag
|
||||
6. Back-merge master to develop (`--no-ff`)
|
||||
7. Create GitHub Release with `gh release create`
|
||||
|
||||
### Versioning: `v{major}.{minor}.{patch}`
|
||||
- **Major**: breaking changes or architectural shifts
|
||||
- **Minor**: new features (modules, digest categories, new pipelines)
|
||||
- **Patch**: bug fixes, config tweaks, content updates
|
||||
|
||||
## URL Policy: Clean URLs (Mandatory)
|
||||
Both V1 (`mkdocs.yml`) and V2 (`v2-mkdocs.yml`) MUST use `use_directory_urls: true` to produce clean URLs like `/kubernetes/` instead of `/kubernetes.html`. **NEVER** enable the MkDocs `offline` plugin — it forces `.html` suffixes on all URLs, breaking SEO authority and thousands of existing deep-links. This is a hard, non-negotiable rule.
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
* [5.4. The Incremental Elite Engine](#54-the-incremental-elite-engine)
|
||||
* [5.5. Decoupled Knowledge Lifecycle (V2 Architecture)](#55-decoupled-knowledge-lifecycle-v2-architecture)
|
||||
* [5.6. Multi-Language Support Policy](#56-multi-language-support-policy)
|
||||
6. [6. The Unified Agentic Database (Knowledge Graph)](#6-the-unified-agentic-database-knowledge-graph)
|
||||
6. [6. The Unified Agentic Database (Coexistence Knowledge Graph)](#6-the-unified-agentic-database-coexistence-knowledge-graph)
|
||||
* [6.1. Database Components](#61-database-components)
|
||||
* [6.2. The 'Database-First' Reasoning Protocol (Zero-Redundancy)](#62-the-database-first-reasoning-protocol-zero-redundancy)
|
||||
* [6.3. Database Lifecycle and Hygiene](#63-database-lifecycle-and-hygiene)
|
||||
@@ -142,7 +142,7 @@ Additionally, as of May 2026, Nubenetes has reached the **Platinum Operational T
|
||||
| :--- | :--- |
|
||||
| **Total Technical Resources (Links)** | **18647+** |
|
||||
| **Specialized MD Pages** | **162** |
|
||||
| **Total Commits** | **5990+** |
|
||||
| **Total Commits** | **6013+** |
|
||||
| **Primary AI Engine** | **Google Gemini (Agentic)** |
|
||||
<!-- HEART_STATS_END -->
|
||||
|
||||
@@ -180,7 +180,7 @@ The growth of Nubenetes reflects the acceleration of the Cloud Native ecosystem.
|
||||
| 6 | 2023 | 30 | 123 | Maintenance & Refinement |
|
||||
| 7 | 2024 | 53 | 218 | Curation Strategy Pivot |
|
||||
| 8 | 2025 | 5 | 20 | Stability & Research Phase |
|
||||
| 9 | 2026 | 2431 | 10,040 | **Agentic AI Surge** (May 2026 Inception) |
|
||||
| 9 | 2026 | 2454 | 10,135 | **Agentic AI Surge** (May 2026 Inception) |
|
||||
<!-- ANNUAL_GROWTH_END -->
|
||||
|
||||
<!-- ANNUAL_CHART_START -->
|
||||
@@ -196,8 +196,8 @@ xychart-beta
|
||||
title "Nubenetes Annual Growth Metrics (2018–2026)"
|
||||
x-axis ["2018", "2019", "2020", "2021", "2022", "2023", "2024", "2025", "2026"]
|
||||
y-axis "Volume (Commits / Estimated New Refs)" 0 --> 11000
|
||||
bar [1445, 586, 8449, 2193, 1660, 123, 218, 20, 10040]
|
||||
bar [350, 142, 2046, 531, 402, 30, 53, 5, 2431]
|
||||
bar [1445, 586, 8449, 2193, 1660, 123, 218, 20, 10135]
|
||||
bar [350, 142, 2046, 531, 402, 30, 53, 5, 2454]
|
||||
```
|
||||
<!-- ANNUAL_CHART_END -->
|
||||
|
||||
@@ -207,7 +207,7 @@ xychart-beta
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| 2026-04 | 25 | 103 | Active Curation |
|
||||
| 2026-05 | 2101 | 8,677 | **Agentic Inception (Gemini Era)** |
|
||||
| 2026-06 | 305 | 1,259 | Active Curation |
|
||||
| 2026-06 | 328 | 1,354 | Active Curation |
|
||||
<!-- MONTHLY_SURGE_END -->
|
||||
|
||||
### 2.4. Content Distribution and Semantic Clustering
|
||||
@@ -268,6 +268,10 @@ The autonomy of Nubenetes is powered by a modern, resilient tech stack that ensu
|
||||
| **Discovery** | Twikit and Playwright | Autonomous scraping and account rotation. |
|
||||
| **Resilience** | Identity Rotation | Evasion of anti-bot blocks using multiple profiles. |
|
||||
| **Deployment** | MkDocs Material & Native GH Pages | High-performance static site generation via native artifact deployment. |
|
||||
| **Intelligence** | News Digest Engine | AI-powered temporal digest across 26 categories (3/6/12 months). |
|
||||
| **Enrichment** | CNCF + GitHub Activity | Landscape graduation status, issue/PR velocity, license change detection. |
|
||||
| **Dedup** | Similarity Engine | URL, content-hash, and title-similarity deduplication (85% threshold). |
|
||||
| **Offline** | PWA Support | Service Worker caching for offline reading of the portal. |
|
||||
|
||||
---
|
||||
|
||||
@@ -394,11 +398,51 @@ Nubenetes operates with two distinct editions to serve different engineering nee
|
||||
- **No stars**: Standard reference documentation and technical resources.
|
||||
- **Multi-Dimensional Tagging (1:N):** Every resource is classified with multiple semantic tags (e.g., `[DE FACTO STANDARD]`, `[GUIDE]`, `[CASE STUDY]`, `[EMERGING]`) providing deep technical context and maturity status.
|
||||
- **Minimalist Inline Summaries**: Resources feature a **"Deep-Dive"** inline tag (using native HTML5 `<details>`) that expands into a rich technical summary without consuming space when collapsed. These summaries use the **Double-Evidence Synthesis** protocol to provide verified architectural insights.
|
||||
- **Semantic Cross-Linking:** The portal autonomously identifies and links related categories within the same strategic dimension (e.g., suggesting `Flux` when reading about `Argo`), creating a cohesive **Industrial Knowledge Graph**.
|
||||
- **Semantic Cross-Linking:** The portal autonomously identifies and links related categories within the same strategic dimension (e.g., suggesting `Flux` when reading about `Argo`), creating a cohesive **Industrial Knowledge Graph**. Additionally, **cross-dimension "See Also" links** connect pages that share technical tags across different dimensions.
|
||||
- **Executive Context**: Every strategic dimension features an AI-generated **State-of-the-Art Introduction** providing high-level architectural context and industry direction before the link listings.
|
||||
- **Source of Truth:** The `v2-docs/` directory (Derived from V1).
|
||||
- **Deployment:** [nubenetes.com/v2/](https://nubenetes.com/v2)
|
||||
|
||||
#### V2 Intelligence Digest (June 2026)
|
||||
The V2 portal includes an **AI-powered Intelligence Digest** system that surfaces the most relevant resources from the last 3, 6, and 12 months across **26 curated categories**:
|
||||
|
||||
| Category Group | Categories |
|
||||
| :--- | :--- |
|
||||
| **Tech Core (9)** | Kubernetes & Orchestration, Containers & Runtime, Networking & Service Mesh, Architecture & Microservices, Data/Messaging/Storage, AI & Agents, MLOps & Data Science, Python/Java/Dev Ecosystem, Linux & System Foundations |
|
||||
| **Platform & Ops (8)** | Security & Compliance, Infrastructure as Code, CI/CD & GitOps, Observability/SRE/Testing, DevOps & Culture, Platform Engineering & DevEx, FinOps & Cloud Cost, Certification & Training |
|
||||
| **Cloud & Enterprise (5)** | AWS, Azure, GCP/OCI/Others, OpenShift/Red Hat, Virtualization & Private Cloud (VMware/Broadcom, Proxmox, Nutanix, KubeVirt) |
|
||||
| **Industry / Geo (4)** | Americas, Europe, Spain, Asia-Pacific |
|
||||
|
||||
**Key features:**
|
||||
- **Trending Now** cards on the index page with the top cross-category items ranked by Gemini AI
|
||||
- **Dedicated digest pages** (`tech-digest.md`, `industry-digest.md`) with tabbed 3/6/12 month views
|
||||
- **Temporal tracking** via `discovered_at` field on all 18,000+ inventory entries
|
||||
- **Company & geo-region classification** extracted by Gemini during ingestion for industry digest
|
||||
- **Automatic staleness detection**: entries enriched >6 months ago are re-evaluated by AI (`last_ai_eval`)
|
||||
|
||||
#### V2 Data Quality and Pipeline Hardening (June 2026)
|
||||
- **CNCF Landscape Integration** (`src/enrichment.py`): Auto-fetches graduation status (Sandbox/Incubating/Graduated/Archived) for CNCF projects to power maturity tags.
|
||||
- **GitHub Activity Enrichment**: Fetches issue/PR velocity and assigns community health scores (active/healthy/low/dormant).
|
||||
- **License Change Detection**: Compares stored licenses with current GitHub data, flagging high-impact changes (e.g., BSL, SSPL switches).
|
||||
- **Deduplication Engine** (`src/dedup.py`): URL normalization, content-hash matching, and title-similarity detection (85% threshold) to eliminate duplicate entries.
|
||||
- **Exception Observability**: All 50+ bare `except: pass` patterns across the pipeline replaced with contextual logging.
|
||||
- **Expanded Discovery**: Autonomous GitHub trending discovery expanded from 6 to 14 search queries covering DevOps, observability, security, IaC, databases, CI/CD, service mesh, and platform engineering.
|
||||
- **Stale Health Re-check**: Online entries older than 30 days are automatically re-validated instead of being skipped.
|
||||
|
||||
#### V2 MkDocs Material Enhancements (June 2026)
|
||||
- **Instant Navigation** with prefetch for SPA-like experience across 140+ pages
|
||||
- **Breadcrumbs** (`navigation.path`) for orientation in deep category hierarchies
|
||||
- **Announcement Bar** promoting the Intelligence Digest
|
||||
- **Tags Plugin** for native clickable cross-page tag navigation
|
||||
- **RSS Feed** for digest page subscription
|
||||
- **PWA/Offline Support** for cached offline reading
|
||||
- **Minify Plugin** for production HTML optimization
|
||||
- **12 Stub Pages Merged** into parent categories with automatic redirects (e.g., `react.md` → `javascript.md`, `chef.md` → `ansible.md`, `oauth.md` → `securityascode.md`)
|
||||
|
||||
#### V2 URL Policy (June 2026)
|
||||
- **Clean URLs enforced**: Both V1 and V2 use `use_directory_urls: true` producing SEO-friendly URLs (e.g., `/kubernetes/` not `/kubernetes.html`).
|
||||
- **Offline plugin permanently removed**: The MkDocs `offline` plugin forces `.html` suffixes on all URLs, breaking thousands of existing deep-links and SEO authority. It is explicitly forbidden in both `CLAUDE.md` and `GEMINI.md` mandates.
|
||||
|
||||
### 5.3. Architecture Comparison Matrix: V1 vs. V2
|
||||
To better understand the dual-nature of the project, the following matrix details the technical and philosophical differences between the two editions:
|
||||
|
||||
@@ -490,24 +534,31 @@ To embrace the diverse global Cloud Native community while maintaining internati
|
||||
|
||||
---
|
||||
|
||||
## 6. The Unified Agentic Database (Knowledge Graph)
|
||||
## 6. The Unified Agentic Database (Coexistence Knowledge Graph)
|
||||
|
||||
Nubenetes now utilizes a **Unified Metadata Architecture** to maintain consistency across V1 and V2 while optimizing AI performance. All links are indexed in a local YAML database that serves as the **Persistent Memory** for our autonomous agents.
|
||||
Nubenetes now utilizes a **Unified SQL & YAML Database Architecture** to maintain consistency across V1 and V2 while optimizing agentic operations and repository efficiency. All curated links and metadata are managed via a coexisting local database engine.
|
||||
|
||||
### 6.1. Database Components
|
||||
1. **Central Inventory ([`data/inventory.yaml`](data/inventory.yaml))**: The universal single source of truth for technical metadata and resource lifecycle.
|
||||
* **Core Data**: `title`, `year`, `stars` (0-5), `description` (V1 Native), `ai_summary` (V2 English), `category`.
|
||||
* **Structural Intelligence**: `hierarchy` (Recursive list up to 10 levels), `v1_locations`, `v2_locations`.
|
||||
* **Platinum Lifecycle**: `content_hash` (SHA256), `health_score` (0-100), `source_provenance`, `social_preview_url`, `mentions_count`.
|
||||
### 6.1. Database Components & SQLite Engine (Option 3 Coexistence)
|
||||
|
||||
To guarantee backward compatibility and Git efficiency, the system operates on a dual-save database coexistence model:
|
||||
1. **SQLite Database & SQL Text ([`data/inventory.sql`](data/inventory.sql))**: The Git source-of-truth. During execution, the SQL script compiles into a temporary in-memory SQLite database, enabling full relational schema access and SQL query optimization. On save, SQLite's native `iterdump()` decompiles it back into a flat SQL text database file where each resource insert occupies a single line for perfect git diff readability.
|
||||
2. **Central Backup Inventory ([`data/inventory.yaml`](data/inventory.yaml))**: Automatically synchronized during database saves. Serves as a backward-compatible interface for legacy markdown parsing scripts.
|
||||
3. **High-Speed Parsing (C-Loader Integration)**: Direct YAML parsing utilizes high-speed native C-extensions (`yaml.CSafeLoader` and `yaml.CSafeDumper`) across all Python scripts (e.g. `v2_optimizer.py`, `reorganize_mosaic.py`, `safety_guard.py`) for a 10x-20x speedup in parsing operations.
|
||||
|
||||
#### 6.1.2. Platinum Lifecycle Schema
|
||||
* **Core Data**: `url` (Primary Key), `title`, `year`, `stars` (0-5), `description` (V1 Native), `ai_summary` (V2 English), `category`.
|
||||
* **Structural Intelligence**: `hierarchy` (Recursive JSON list), `tags` (JSON list), `v1_locations`, `v2_locations`, `youtube_mosaic` (JSON dict).
|
||||
* **Platinum Lifecycle**: `content_hash` (SHA256 fingerprint), `health_score` (0-100), `source_provenance`, `social_preview_url`, `mentions_count`, `addition_method`.
|
||||
|
||||
### 6.2. The 'Database-First' Reasoning Protocol (Zero-Redundancy)
|
||||
To maximize economic efficiency and maintain the **30-minute execution standard**, all AI agents follow a **Database-First** and **Zero-Redundancy** protocol:
|
||||
1. **Local Lookup**: Before initiating any Gemini call, the agent checks if the URL is already indexed in [`data/inventory.yaml`](data/inventory.yaml).
|
||||
2. **Zero-Redundancy Pipeline**: The V2 Optimizer leverages health and metadata (`gh_stars`, `gh_license`) already validated by the `IntelligentLinkCleaner`. If a resource is marked as `status: online` and has recent metadata, V2 bypasses redundant network checks.
|
||||
3. **Smart Grounding (Search Retrieval)**: AI agents only activate grounding-heavy calls (Google Search) for resources that are new, missing metadata, or flagged for `needs_ai_refresh`. This reduces latencia by >80% for 15k+ link archives.
|
||||
4. **Insight Reuse**: If the resource exists with valid metadata, the agent **reuses existing insights**, reducing API traffic to zero.
|
||||
5. **Memory Efficiency Tracking**: The system tracks **Cache Hit Ratios** and **Estimated Token Savings** in every Intelligence Report.
|
||||
6. **Mandatory Persistence**: Modified YAML files are automatically injected into Pull Requests, ensuring that "System Memory" is version-controlled and shared across all workflows.
|
||||
1. **Local Lookup**: Before initiating any Gemini call, the agent queries the compiled SQLite/SQL database to see if the URL is already indexed.
|
||||
2. **Domain Reputation Registry**: In `main.py`, scraping/health-check success rates are recorded under `domain_reputation` inside `health_learning.json` for adaptive timeout and scraping rotation.
|
||||
3. **Stateful Debate Caching**: In `v2_debate.py`, consensus evaluations for borderline resources are cached based on the SHA256 hash of their combined metadata (`title`, `description`, `tags`). On cache hits, the agent skips redundant LLM calls and retrieves the score directly.
|
||||
4. **Pre-Commit Markdown Lint Hook**: In `src/pre_commit_schema_check.py`, a local Git pre-commit hook automatically runs on developer changes to enforce heading rules (no emojis/ampersands in titles), protocol integrity, link bracket spacing, and duplicate checks in docs markdown.
|
||||
5. **Insight Reuse**: If the resource exists with valid metadata, the agent **uses existing insights**, reducing API traffic to zero.
|
||||
6. **Memory Efficiency Tracking**: The system tracks **Cache Hit Ratios** and **Estimated Token Savings** in every Intelligence Report.
|
||||
7. **Mandatory Persistence**: Modified databases are automatically injected into Pull Requests, ensuring that "System Memory" is version-controlled and shared across all workflows.
|
||||
|
||||
### 6.3. Database Lifecycle and Hygiene
|
||||
To maintain a high-performance "Single Source of Truth", Nubenetes implements automated hygiene protocols:
|
||||
@@ -774,6 +825,7 @@ The following matrix defines our strategic model tiering across all workflows:
|
||||
| **PR Guardian** | PR Presubmit | **Gemini Flash/Lite** | Tier 1 | Rapid syntax and mandate format linting. | **Medium** |
|
||||
| **Curator (X/RSS)** | Agentic Curator | **Gemini Pro** | Tier 2 | Deep reasoning for human/social context. | **Low (Burst)** |
|
||||
| **Auditor** | V2 Elite Builder | **Gemini Pro** | Tier 2 | High-fidelity verification of [ELITE] resources. | **Medium** |
|
||||
| **Fast-Pass Evaluator** | Curation / V2 Builder | **Gemini Flash/Lite** | Tier 1 | Rapid single-call screening for obvious consensus/non-consensus. | **High** |
|
||||
| **Debater Personas** | Curation / V2 Builder | **Gemini Flash/Lite** | Tier 1 | Independent multi-perspective evaluations and rebuttals. | **High** |
|
||||
| **Debate Synthesis** | Curation / V2 Builder | **Gemini Pro** | Tier 2 | High-fidelity final consensus and summary synthesis. | **Medium** |
|
||||
|
||||
@@ -793,17 +845,22 @@ The heart of the new Nubenetes is a suite of AI Agents that operate on our `deve
|
||||
- **Elite Selection:** Scans the massive V1 archive to select the "Elite" top-tier resources.
|
||||
- **2026 Taxonomy:** Reorganizes content into high-density dimensions using **relevance-first sorting**.
|
||||
- **MVQ Hardening:** Automatically identifies stale repositories to exclude them from the Elite portal.
|
||||
- **Tags Page Cap (Recommendation #5)**: Caps the technical tag listings in `tags.md` to 100 entries per tag block (sorted by stars/year) to prevent DOM bloat, providing fallback links to the V1 Historical Archive.
|
||||
3. **IntelligentHealthChecker ([`src/intelligent_health_checker.py`](src/intelligent_health_checker.py))**:
|
||||
- **Resilience:** asynchronous health checks with 3x retry and identity rotation.
|
||||
- **V1 Integrity:** Focuses on link validity (removing 404s) to ensure the exhaustive V1 archive remains accessible.
|
||||
- **Domain failure audits (Recommendation #2)**: Automatically logs consecutive connection failures per domain in `health_learning.json` and drops check timeouts from 12s to 3s when consecutive failures $\ge 3$ to avoid hanging.
|
||||
- **Transparency:** Provides detailed, real-time unbuffered logging of all cleaning operations.
|
||||
4. **DebatePanelEngine ([`src/v2_debate.py`](src/v2_debate.py))**:
|
||||
- **Persona-based Evaluation**: Coordinates specialized opinions across Security Architect, SRE, and AI Engineer personas.
|
||||
- **Fast-Pass screening (Recommendation #3)**: Runs a single Flash model call at start; clear-cut cases bypass the full panel debate immediately.
|
||||
- **Persona-based Evaluation**: Coordinates specialized expert opinions (Security Architect, SRE, and AI Engineer personas) for borderline cases (initial scores in `[60, 75]`).
|
||||
- **Consensus Resolution**: Resolves high score-divergences (>= 15 points) using a round-robin debate structure.
|
||||
- **Auto-Corrective Memory**: Appends resolution logs to `src/memory/health_learning.json` for persistent, few-shot alignment.
|
||||
5. **Resilient Architecture Core**:
|
||||
- **Exponential Backoff**: Intelligent `tenacity`-based retry logic in `gemini_utils.py` gracefully handles 429 Rate Limits before triggering the Circuit Breaker.
|
||||
- **Flash-First Architecture**: Prioritizes Gemini Flash/Lite models for high-density Analyst tasks, enabling processing of 10,000+ resources within the 6-hour GitHub Actions limit through 100-item batching and 2-second safety delays.
|
||||
- **Curation Ingestion Toggle**: Supports `ENABLE_TWITTER_CURATION` environment flag to dynamically toggle Playwright-based Twikit extraction when remote scraping blocks.
|
||||
- **Adaptive Timeout & UA Rotation**: Adapts request headers and reduces health check timeouts dynamically under network throttle/block conditions.
|
||||
- **Programmatic Smart Injection (Option B)**: The system extracts document headers and has Gemini Flash choose the target header, performing the actual line insertion using Python. This bypasses the need for Gemini Pro to rewrite entire documents, slashing API usage and preventing 429 errors.
|
||||
- **Incremental Persistence (Mandate 22)**: Implements a dual-phase auto-save mechanism that flushes the `inventory.yaml` database to disk periodically **without waiting for the workflow to finish**:
|
||||
* **Metadata Phase**: Saves every **500 GitHub repositories** processed.
|
||||
@@ -819,17 +876,20 @@ To eliminate individual LLM rating bias, resolve borderline cases, and prevent a
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["New Resource Found"] --> B["Persona 1: Security Architect"]
|
||||
A --> C["Persona 2: Cloud Native SRE"]
|
||||
A --> D["Persona 3: AI Platform Engineer"]
|
||||
B --> E["Independent Evaluations"]
|
||||
A["New Resource Found"] --> FP["Fast-Pass Evaluator (Flash)"]
|
||||
FP -->|Confident Score Outside 60-75| G["Accept / Reject Directly"]
|
||||
FP -->|Borderline Score 60-75| B["Persona 1: Security Architect"]
|
||||
FP -->|Borderline Score 60-75| C["Persona 2: Cloud Native SRE"]
|
||||
FP -->|Borderline Score 60-75| D["Persona 3: AI Platform Engineer"]
|
||||
B --> E["Independent expert Evaluations"]
|
||||
C --> E
|
||||
D --> E
|
||||
E -->|Scores Diverge >= 15 points| F["Trigger Debate Round"]
|
||||
E -->|Scores Converge| G["Accept / Reject Directly"]
|
||||
E -->|Expert Scores Diverge >= 15 points| F["Trigger Debate Round"]
|
||||
E -->|Expert Scores Converge| G
|
||||
F --> H["Round-Robin Discussion: Argue Pros and Cons"]
|
||||
H --> I["Consensus Reached and Final Score Assigned"]
|
||||
I --> J["Save Decision to Persistent Memory JSON"]
|
||||
G --> J
|
||||
```
|
||||
|
||||
#### 8.3.1. Panel of Expert Personas
|
||||
@@ -839,14 +899,15 @@ The panel consists of three distinct virtual expert roles, each prompting Gemini
|
||||
* **AI Platform Engineer**: Judges developer productivity, ease of integration with the modern AI stack (e.g., Model Context Protocol (MCP) tools), and overall 2026 Cloud Native architectural relevance.
|
||||
|
||||
#### 8.3.2. Protocol Execution Flow
|
||||
The debate protocol executes asynchronously in three distinct phases:
|
||||
1. **Phase 1: Independent Evaluation**: Each expert persona independently evaluates the resource (using Google Search Grounding to check the live state of the project). They assign an architectural impact score (0–100) and write a 1–2 sentence justification.
|
||||
2. **Phase 2: Divergence Assessment and Rebuttal**: If the difference between the highest and lowest assigned scores is **$\ge 15$ points**, a debate round is triggered. Each expert receives the scores and justifications of their peers and is asked to defend or revise their score in a rebuttal round.
|
||||
3. **Phase 3: Consensus and Synthesis**: The final consensus score is the average of the revised scores of the three personas. A fourth agent (Curation Synthesis Agent) compiles the justifications and rebuttals, generating a refined, high-density technical summary (2–5 sentences) and selecting precise ecosystem tags (e.g., `[DE FACTO STANDARD]`, `[ENTERPRISE-STABLE]`, `[EMERGING]`).
|
||||
The debate protocol executes in the following phases:
|
||||
1. **Fast-Pass Screening**: A single-call evaluator rates the resource. If the rating is highly confident (score $\le 59$ or $\ge 76$), it bypasses the expert panel entirely.
|
||||
2. **Expert Evaluation (Borderline Cases)**: If the screening rating is borderline (`[60, 75]`), the three expert personas (Security, SRE, and AI/Developer DX) independently evaluate the resource (using Google Search Grounding to check the live state of the project). They assign an architectural impact score (0–100) and write a 1–2 sentence justification.
|
||||
3. **Divergence Assessment and Rebuttal**: If the difference between the highest and lowest assigned scores is **$\ge 15$ points**, a debate round is triggered. Each expert receives the scores and justifications of their peers and is asked to defend or revise their score in a rebuttal round.
|
||||
4. **Consensus and Synthesis**: The final consensus score is the average of the revised scores of the three personas. A curation synthesis agent compiles the justifications and rebuttals, generating a refined, high-density technical summary (2–5 sentences) and selecting precise ecosystem tags (e.g., `[DE FACTO STANDARD]`, `[ENTERPRISE-STABLE]`, `[EMERGING]`).
|
||||
|
||||
#### 8.3.3. Integration Points
|
||||
* **Discovery Ingestion**: Hooked into [`src/agentic_curator.py`](src/agentic_curator.py) for new links with borderline initial scores between `70` and `85`.
|
||||
* **V2 Portal Auditing**: Hooked into [`src/v2_optimizer.py`](src/v2_optimizer.py) during builds for high-impact candidates (`[DE FACTO STANDARD]`, `[ENTERPRISE-STABLE]`) or borderline candidates (3–4 stars).
|
||||
* **Discovery Ingestion**: Hooked into [`src/agentic_curator.py`](src/agentic_curator.py) for new links with borderline initial scores.
|
||||
* **V2 Portal Auditing**: Hooked into [`src/v2_optimizer.py`](src/v2_optimizer.py) during builds for high-impact candidates or borderline candidates.
|
||||
* **Persistent Memory Log**: The final consensus score, justifications, rebuttals, and metadata are saved to `src/memory/health_learning.json` under `resolved_debates` to serve as few-shot training examples for future curation runs.
|
||||
|
||||
---
|
||||
@@ -1173,7 +1234,11 @@ To maintain transparency and ease of navigation, all key configuration, database
|
||||
- **Health Check Logic:** [`src/intelligent_health_checker.py`](src/intelligent_health_checker.py) - Link rot prevention and canonical updates.
|
||||
- **Twikit Ingestion:** [`src/ingestion_twikit.py`](src/ingestion_twikit.py) - X.com scraping and account rotation logic.
|
||||
- **Backup Ingestion:** [`src/ingestion_backup.py`](src/ingestion_backup.py) - Manual and historical JSON data processing.
|
||||
- **Discovery Engine:** [`src/autonomous_discovery.py`](src/autonomous_discovery.py) - Multi-source technical news extraction.
|
||||
- **Discovery Engine:** [`src/autonomous_discovery.py`](src/autonomous_discovery.py) - Multi-source technical news extraction (14 GitHub search queries).
|
||||
- **News Digest Engine:** [`src/news_digest.py`](src/news_digest.py) - AI-powered temporal digest across 26 categories with Gemini ranking (3/6/12 months).
|
||||
- **Enrichment Pipeline:** [`src/enrichment.py`](src/enrichment.py) - CNCF Landscape integration, GitHub activity enrichment, and license change detection.
|
||||
- **Deduplication Engine:** [`src/dedup.py`](src/dedup.py) - URL normalization, content-hash, and title-similarity dedup (85% threshold).
|
||||
- **Backfill Utility:** [`scripts/backfill_discovered_at.py`](scripts/backfill_discovered_at.py) - One-shot `discovered_at` population for existing entries.
|
||||
- **Gemini Utils:** [`src/gemini_utils.py`](src/gemini_utils.py) - AI model discovery, rate limiting, and session tracking.
|
||||
- **Markdown Logic:** [`src/markdown_ast.py`](src/markdown_ast.py) - Sophisticated parsing of repository content.
|
||||
- **Observability:** [`src/logger.py`](src/logger.py) | [`src/report_generator.py`](src/report_generator.py) - Execution transparency and visual reporting.
|
||||
|
||||
+19615
File diff suppressed because it is too large
Load Diff
+282319
-151867
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<strong>New:</strong> <a href="./tech-digest/">Intelligence Digest</a> — AI-curated trending resources across 26 categories
|
||||
{% endblock %}
|
||||
Vendored
+193
-2
@@ -3,6 +3,15 @@
|
||||
* Color Palette: Deep Space Black & Neon Cyan
|
||||
*/
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.md-content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* LIGHT MODE - Modern, Crisp, High Contrast */
|
||||
--md-primary-fg-color: #09090b; /* Zinc 950 */
|
||||
@@ -807,6 +816,13 @@ a {
|
||||
contain-intrinsic-size: auto 500px;
|
||||
}
|
||||
|
||||
/* Defer rendering of large resource lists in category pages */
|
||||
.md-typeset > ul,
|
||||
.md-typeset > ol {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 200px;
|
||||
}
|
||||
|
||||
/* Collapsible tag lists on the tags index page */
|
||||
.v2-tag-section details {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
@@ -908,13 +924,188 @@ input[type="text"] {
|
||||
|
||||
/* Coarse touch pointer optimizations (Mobile AA targets) */
|
||||
@media (pointer: coarse) {
|
||||
.md-tag,
|
||||
.md-button,
|
||||
.md-tag,
|
||||
.md-button,
|
||||
.v2-tag-section summary {
|
||||
min-block-size: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- */
|
||||
/* ANNOUNCEMENT BAR */
|
||||
/* ---------------------------------------------------- */
|
||||
|
||||
.md-banner {
|
||||
background: linear-gradient(135deg, #0ea5e9, #22d3ee);
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.md-banner a {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-banner {
|
||||
background: linear-gradient(135deg, #0284c7, #06b6d4);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- */
|
||||
/* TRENDING NOW — NEWS DIGEST CARDS */
|
||||
/* ---------------------------------------------------- */
|
||||
|
||||
.trending-section {
|
||||
margin: 32px 0;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(14, 165, 233, 0.15);
|
||||
background: linear-gradient(180deg, rgba(14, 165, 233, 0.03) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .trending-section {
|
||||
border-color: rgba(34, 211, 238, 0.15);
|
||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.05) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.trending-section__title {
|
||||
font-size: 1.4em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trending-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.trending-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--md-primary-bg-color--light);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trending-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .trending-card {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(24, 24, 27, 0.4);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .trending-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(34, 211, 238, 0.12);
|
||||
}
|
||||
|
||||
.trending-card__category {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--md-accent-fg-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trending-card__title {
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.trending-card__title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trending-card__title a:hover {
|
||||
color: var(--md-accent-fg-color);
|
||||
}
|
||||
|
||||
.trending-card__meta {
|
||||
font-size: 0.8em;
|
||||
color: var(--md-primary-fg-color--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trending-card__why {
|
||||
font-size: 0.82em;
|
||||
line-height: 1.45;
|
||||
color: var(--md-primary-fg-color--light);
|
||||
}
|
||||
|
||||
.trending-card__impact {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.trending-card__impact--critical {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.trending-card__impact--high {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.trending-card__impact--medium {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
/* Digest link cards (CTA to full digest pages) */
|
||||
.digest-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.digest-link-card {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--md-accent-fg-color);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.digest-link-card:hover {
|
||||
background: var(--md-accent-fg-color);
|
||||
color: #ffffff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .digest-link-card:hover {
|
||||
color: #09090b;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Vendored
+4
-3
@@ -13,9 +13,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
// Do not show on the homepage, video hub index page, or technical tags index page (performance)
|
||||
const h1 = contentArea.querySelector("h1");
|
||||
if (h1 && (
|
||||
h1.textContent.includes("Nubenetes Elite Portal (V2)") ||
|
||||
h1.textContent.includes("Agentic Video Hub") ||
|
||||
h1.textContent.includes("Technical Tags Index")
|
||||
h1.textContent.includes("Nubenetes Elite Portal (V2)") ||
|
||||
h1.textContent.includes("Agentic Video Hub") ||
|
||||
h1.textContent.includes("Technical Tags Index") ||
|
||||
h1.textContent.includes("Intelligence Digest")
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ yt-dlp
|
||||
youtube-transcript-api
|
||||
mkdocs-redirects>=1.2.3
|
||||
mkdocs-minify-plugin>=0.8.0
|
||||
mkdocs-rss-plugin>=1.15.0
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Backfill script: adds discovered_at to inventory entries that lack it.
|
||||
Priority: gh_pushed > last_checked > year > default "2024-01-01T00:00:00"
|
||||
Run once: python -m scripts.backfill_discovered_at
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from datetime import datetime
|
||||
from src.inventory_manager import load_inventory, save_inventory
|
||||
from src.config import MADRID_TZ
|
||||
|
||||
|
||||
def backfill():
|
||||
inv = load_inventory()
|
||||
updated = 0
|
||||
total = 0
|
||||
|
||||
for url, entry in inv.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
total += 1
|
||||
if entry.get("discovered_at"):
|
||||
continue
|
||||
|
||||
discovered = None
|
||||
|
||||
gh_pushed = entry.get("gh_pushed")
|
||||
if gh_pushed and isinstance(gh_pushed, str) and len(gh_pushed) >= 10:
|
||||
discovered = gh_pushed
|
||||
|
||||
if not discovered:
|
||||
last_checked = entry.get("last_checked")
|
||||
if isinstance(last_checked, (int, float)) and last_checked > 0:
|
||||
try:
|
||||
discovered = datetime.fromtimestamp(last_checked, tz=MADRID_TZ).isoformat()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
if not discovered:
|
||||
year = entry.get("year", "")
|
||||
if isinstance(year, str) and year.isdigit() and len(year) == 4:
|
||||
discovered = f"{year}-06-01T00:00:00+02:00"
|
||||
elif isinstance(year, int):
|
||||
discovered = f"{year}-06-01T00:00:00+02:00"
|
||||
|
||||
if not discovered:
|
||||
discovered = "2024-01-01T00:00:00+02:00"
|
||||
|
||||
entry["discovered_at"] = discovered
|
||||
updated += 1
|
||||
|
||||
print(f"Backfill complete: {updated}/{total} entries updated with discovered_at")
|
||||
save_inventory(inv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
backfill()
|
||||
+34
-17579
File diff suppressed because it is too large
Load Diff
+21
-6
@@ -38,7 +38,8 @@ async def _get_github_activity(url: str) -> Dict:
|
||||
"gh_pushed": data.get("pushed_at"),
|
||||
"gh_license": data.get("license", {}).get("spdx_id", "N/A")
|
||||
}
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] fetch GitHub activity for {url}: {str(e)[:100]}")
|
||||
return {}
|
||||
|
||||
async def _deep_fetch_content(url: str) -> Tuple[str, Dict]:
|
||||
@@ -61,7 +62,8 @@ async def _deep_fetch_content(url: str) -> Tuple[str, Dict]:
|
||||
img_match = re.search(r'meta property="og:image" content="(.*?)"', resp.text)
|
||||
if img_match: og_image = img_match.group(1)
|
||||
return resp.text, {"og_image": og_image}
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] deep fetch content for {url}: {str(e)[:100]}")
|
||||
return "", {}
|
||||
|
||||
async def evaluate_extracted_assets(raw_assets: List[Dict]) -> Dict[str, Dict]:
|
||||
@@ -76,7 +78,8 @@ async def evaluate_extracted_assets(raw_assets: List[Dict]) -> Dict[str, Dict]:
|
||||
try:
|
||||
memory_data = json.load(open(memory_file, "r"))
|
||||
domain_blacklist = set(memory_data.get("blacklisted_domains", []))
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load blacklist from health_learning.json: {str(e)[:100]}")
|
||||
|
||||
# 1. Pre-filter
|
||||
for asset in raw_assets:
|
||||
@@ -140,7 +143,10 @@ async def evaluate_extracted_assets(raw_assets: List[Dict]) -> Dict[str, Dict]:
|
||||
"- Assign tags. You MUST include:\n"
|
||||
" 1. 1 to 2 maturity tags from: [DE FACTO STANDARD], [ENTERPRISE-STABLE], [EMERGING], [GUIDE], [CASE STUDY], [COMMUNITY-TOOL], [LEGACY].\n"
|
||||
" 2. Fine-grained technical/architectural tags from the content (e.g., [EBPF], [WASM], [GITOPS], [IAC], [SERVICE-MESH], [SERVERLESS], [MLOPS], [DB]). Keep them uppercase and wrapped in brackets.\n"
|
||||
"Respond ONLY JSON list: [{\"url\": \"...\", \"impact_score\": int, \"reputation_penalty\": bool, \"reputation_summary\": \"...\", \"pub_date\": \"YYYY-MM-DD\", \"primary_category\": \"...\", \"suggested_new_category\": \"...\", \"title\": \"...\", \"desc\": \"...\", \"en_summary\": \"High-density summary...\", \"language\": \"...\", \"type\": \"...\", \"level\": \"...\", \"technical_hierarchy\": [...], \"tags\": [...], \"is_microservice\": bool}, ...]\n\n"
|
||||
"PHASE 5: COMPANY & GEO CLASSIFICATION\n"
|
||||
"- Identify 'company': The company/organization that authored or is the primary subject (e.g., 'Google', 'Netflix', 'CNCF', 'Independent').\n"
|
||||
"- Identify 'geo_region': The HQ region of that company. Use one of: 'americas', 'europe', 'spain', 'asia_pacific', 'global'.\n"
|
||||
"Respond ONLY JSON list: [{\"url\": \"...\", \"impact_score\": int, \"reputation_penalty\": bool, \"reputation_summary\": \"...\", \"pub_date\": \"YYYY-MM-DD\", \"primary_category\": \"...\", \"suggested_new_category\": \"...\", \"title\": \"...\", \"desc\": \"...\", \"en_summary\": \"High-density summary...\", \"language\": \"...\", \"type\": \"...\", \"level\": \"...\", \"technical_hierarchy\": [...], \"tags\": [...], \"is_microservice\": bool, \"company\": \"...\", \"geo_region\": \"...\"}, ...]\n\n"
|
||||
"RESOURCES:\n" + "\n".join([f"- {d['asset']['url']}: (MVQ Penalty: {d['mvq_penalty']}) {d['content']}" for d in batch_data])
|
||||
)
|
||||
|
||||
@@ -205,9 +211,16 @@ async def evaluate_extracted_assets(raw_assets: List[Dict]) -> Dict[str, Dict]:
|
||||
"reputation_status": "Vetted" if not data.get("reputation_penalty") else "Suspicious",
|
||||
"reputation_summary": data.get("reputation_summary", ""),
|
||||
"source_provenance": d["asset"].get("source_type", "Social"), "social_preview_url": d["rich_meta"].get("og_image", ""),
|
||||
"company": data.get("company", ""), "geo_region": data.get("geo_region", ""),
|
||||
"category": primary_cat, "status": "online", "last_checked": datetime.now().timestamp(),
|
||||
"discovered_at": datetime.now(MADRID_TZ).isoformat(),
|
||||
"last_ai_eval": datetime.now(MADRID_TZ).isoformat(),
|
||||
"suggested_new_category": data.get("suggested_new_category", ""),
|
||||
"addition_method": "automatic", **d["gh_meta"]
|
||||
"addition_method": {
|
||||
"rss": "rss_ingestion", "GitHub Trending": "github_trending",
|
||||
"Twitter": "twitter_ingestion", "nubenetes": "manual"
|
||||
}.get(d["asset"].get("source_type", ""), "automatic"),
|
||||
**d["gh_meta"]
|
||||
}
|
||||
if "youtube.com" in url or "youtu.be" in url:
|
||||
title_desc = f"{data['title']} {data['desc']}".lower()
|
||||
@@ -250,7 +263,9 @@ class AgenticCurator:
|
||||
prompt = "Identify 5 high-quality Cloud Native or K8s engineering blogs or 'Awesome' repos active in 2026. Return ONLY JSON list of URLs."
|
||||
try:
|
||||
return await call_gemini_with_retry(prompt, use_grounding=True)
|
||||
except: return []
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] autonomous source discovery: {str(e)[:100]}")
|
||||
return []
|
||||
|
||||
async def decide_smart_injection(self, content: str, asset: Dict) -> str:
|
||||
# Extract headers from the markdown content
|
||||
|
||||
@@ -2,20 +2,31 @@ import aiohttp
|
||||
import json
|
||||
import httpx
|
||||
import re
|
||||
from src.config import GEMINI_API_KEY, NUBENETES_CATEGORIES
|
||||
from src.config import GEMINI_API_KEY, NUBENETES_CATEGORIES, GH_TOKEN
|
||||
from src.gemini_utils import call_gemini_with_retry
|
||||
from src.logger import log_event
|
||||
|
||||
async def fetch_github_trending_cloud_native() -> list[dict]:
|
||||
queries = [
|
||||
"topic:kubernetes+stars:>1000",
|
||||
"topic:mcp-server+stars:>0",
|
||||
"topic:kubernetes+stars:>1000",
|
||||
"topic:mcp-server+stars:>0",
|
||||
"topic:model-context-protocol+stars:>0",
|
||||
"topic:ai-agents+stars:>50",
|
||||
"awesome+stars:>1000",
|
||||
"topic:generative-ai+stars:>500"
|
||||
"topic:generative-ai+stars:>500",
|
||||
"topic:devops+stars:>500",
|
||||
"topic:observability+stars:>200",
|
||||
"topic:cloud-security+stars:>200",
|
||||
"topic:terraform+stars:>500",
|
||||
"topic:database+stars:>500",
|
||||
"topic:cicd+stars:>200",
|
||||
"topic:service-mesh+stars:>100",
|
||||
"topic:platform-engineering+stars:>100",
|
||||
]
|
||||
all_repos = []
|
||||
headers = {'Accept': 'application/vnd.github.v3+json'}
|
||||
if GH_TOKEN:
|
||||
headers['Authorization'] = f'token {GH_TOKEN}'
|
||||
async with aiohttp.ClientSession(headers=headers) as session:
|
||||
for q in queries:
|
||||
url = f"https://api.github.com/search/repositories?q={q}&sort=updated&order=desc"
|
||||
@@ -29,7 +40,9 @@ async def fetch_github_trending_cloud_native() -> list[dict]:
|
||||
"url": repo['html_url'],
|
||||
"desc": repo['description'] or "No description provided."
|
||||
})
|
||||
except: continue
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] GitHub search query '{q}': {str(e)[:100]}")
|
||||
continue
|
||||
return all_repos
|
||||
|
||||
async def discover_trending_assets() -> list[dict]:
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Dict, List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
from src.inventory_manager import load_inventory, save_inventory
|
||||
from src.logger import log_event
|
||||
|
||||
TRACKING_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
|
||||
"ref", "source", "fbclid", "gclid", "mc_cid", "mc_eid", "s", "share"}
|
||||
|
||||
|
||||
def normalize_url_deep(url: str) -> str:
|
||||
parsed = urlparse(url.strip().lower())
|
||||
scheme = "https"
|
||||
netloc = parsed.netloc.removeprefix("www.")
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
params = parse_qs(parsed.query)
|
||||
clean_params = {k: v for k, v in params.items() if k not in TRACKING_PARAMS}
|
||||
query = urlencode(clean_params, doseq=True) if clean_params else ""
|
||||
return urlunparse((scheme, netloc, path, "", query, ""))
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
if not title:
|
||||
return ""
|
||||
t = title.lower().strip()
|
||||
t = re.sub(r'^[\w.-]+\.\w{2,}:\s*', '', t)
|
||||
t = re.sub(r'[^\w\s]', ' ', t)
|
||||
t = re.sub(r'\s+', ' ', t).strip()
|
||||
return t
|
||||
|
||||
|
||||
def find_url_duplicates(inventory: Dict) -> List[Tuple[str, str]]:
|
||||
norm_map = defaultdict(list)
|
||||
for url in inventory:
|
||||
if not isinstance(inventory[url], dict):
|
||||
continue
|
||||
deep = normalize_url_deep(url)
|
||||
norm_map[deep].append(url)
|
||||
|
||||
duplicates = []
|
||||
for norm, urls in norm_map.items():
|
||||
if len(urls) > 1:
|
||||
for i in range(1, len(urls)):
|
||||
duplicates.append((urls[0], urls[i]))
|
||||
return duplicates
|
||||
|
||||
|
||||
def find_hash_duplicates(inventory: Dict) -> List[List[str]]:
|
||||
hash_map = defaultdict(list)
|
||||
for url, entry in inventory.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
ch = entry.get("content_hash")
|
||||
if ch and ch != "N/A":
|
||||
hash_map[ch].append(url)
|
||||
return [urls for urls in hash_map.values() if len(urls) > 1]
|
||||
|
||||
|
||||
def find_title_duplicates(inventory: Dict, threshold: float = 0.85) -> List[Tuple[str, str, float]]:
|
||||
entries = []
|
||||
for url, entry in inventory.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
title = entry.get("title", "")
|
||||
norm = normalize_title(title)
|
||||
if len(norm) < 10:
|
||||
continue
|
||||
entries.append((url, norm, entry.get("stars", 0)))
|
||||
|
||||
log_event(f"[Dedup] Building title index for {len(entries)} entries...")
|
||||
|
||||
prefix_groups = defaultdict(list)
|
||||
for url, norm, stars in entries:
|
||||
words = norm.split()
|
||||
prefix = " ".join(words[:3]) if len(words) >= 3 else norm
|
||||
prefix_groups[prefix].append((url, norm, stars))
|
||||
|
||||
duplicates = []
|
||||
checked = 0
|
||||
for prefix, group in prefix_groups.items():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
for i in range(len(group)):
|
||||
for j in range(i + 1, len(group)):
|
||||
url1, norm1, stars1 = group[i]
|
||||
url2, norm2, stars2 = group[j]
|
||||
if stars1 >= 4 and stars2 >= 4:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, norm1, norm2).ratio()
|
||||
if ratio >= threshold:
|
||||
duplicates.append((url1, url2, ratio))
|
||||
checked += 1
|
||||
if checked % 500 == 0:
|
||||
log_event(f"[Dedup] Checked {checked}/{len(prefix_groups)} prefix groups...")
|
||||
|
||||
log_event(f"[Dedup] Title scan complete: {len(duplicates)} potential duplicates found")
|
||||
return duplicates
|
||||
|
||||
|
||||
def _entry_score(entry: Dict) -> Tuple:
|
||||
return (
|
||||
entry.get("stars", 0),
|
||||
1 if entry.get("ai_summary") else 0,
|
||||
1 if entry.get("hierarchy") else 0,
|
||||
len(entry.get("tags", [])),
|
||||
-len(str(entry.get("url", "")))
|
||||
)
|
||||
|
||||
|
||||
def resolve_duplicates(inventory: Dict, duplicate_pairs: List[Tuple[str, str, float]]) -> int:
|
||||
resolved = 0
|
||||
seen = set()
|
||||
|
||||
for url1, url2, score in sorted(duplicate_pairs, key=lambda x: -x[2]):
|
||||
if url1 in seen or url2 in seen:
|
||||
continue
|
||||
|
||||
entry1 = inventory.get(url1, {})
|
||||
entry2 = inventory.get(url2, {})
|
||||
|
||||
if not isinstance(entry1, dict) or not isinstance(entry2, dict):
|
||||
continue
|
||||
|
||||
score1 = _entry_score(entry1)
|
||||
score2 = _entry_score(entry2)
|
||||
|
||||
if score1 >= score2:
|
||||
winner, loser = url1, url2
|
||||
else:
|
||||
winner, loser = url2, url1
|
||||
|
||||
inventory[loser]["status"] = "duplicate"
|
||||
inventory[loser]["duplicate_of"] = winner
|
||||
seen.add(loser)
|
||||
resolved += 1
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
async def run_dedup(dry_run: bool = True) -> Dict:
|
||||
log_event("STARTING DEDUPLICATION SCAN", section_break=True)
|
||||
inventory = load_inventory()
|
||||
|
||||
url_dups = find_url_duplicates(inventory)
|
||||
log_event(f"[Dedup] URL duplicates: {len(url_dups)}")
|
||||
|
||||
hash_groups = find_hash_duplicates(inventory)
|
||||
hash_dups = []
|
||||
for group in hash_groups:
|
||||
for i in range(1, len(group)):
|
||||
hash_dups.append((group[0], group[i], 1.0))
|
||||
log_event(f"[Dedup] Content hash duplicates: {len(hash_dups)}")
|
||||
|
||||
title_dups = find_title_duplicates(inventory)
|
||||
|
||||
all_dups = [(u1, u2, 1.0) for u1, u2 in url_dups] + hash_dups + title_dups
|
||||
unique_pairs = {}
|
||||
for u1, u2, s in all_dups:
|
||||
key = tuple(sorted([u1, u2]))
|
||||
if key not in unique_pairs or s > unique_pairs[key]:
|
||||
unique_pairs[key] = s
|
||||
deduped_pairs = [(k[0], k[1], v) for k, v in unique_pairs.items()]
|
||||
|
||||
stats = {
|
||||
"url_duplicates": len(url_dups),
|
||||
"hash_duplicates": len(hash_dups),
|
||||
"title_duplicates": len(title_dups),
|
||||
"total_unique_pairs": len(deduped_pairs),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
log_event(f"[Dedup] DRY RUN — {len(deduped_pairs)} duplicates found, no changes made")
|
||||
for u1, u2, score in sorted(deduped_pairs, key=lambda x: -x[2])[:20]:
|
||||
t1 = inventory.get(u1, {}).get("title", "?")[:60]
|
||||
t2 = inventory.get(u2, {}).get("title", "?")[:60]
|
||||
log_event(f" [{score:.0%}] {t1} <-> {t2}")
|
||||
else:
|
||||
resolved = resolve_duplicates(inventory, deduped_pairs)
|
||||
stats["resolved"] = resolved
|
||||
save_inventory(inventory)
|
||||
log_event(f"[Dedup] Resolved {resolved} duplicates")
|
||||
|
||||
log_event(f"DEDUP COMPLETE: {stats}")
|
||||
return stats
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_dedup(dry_run=True))
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
import httpx
|
||||
from src.logger import log_event
|
||||
from src.gemini_utils import call_gemini_with_retry, fetch_youtube_metadata
|
||||
from src.inventory_manager import load_inventory, save_inventory
|
||||
|
||||
INVENTORY_PATH = "data/inventory.yaml"
|
||||
|
||||
@@ -72,8 +73,7 @@ async def main():
|
||||
|
||||
force_enrich = os.getenv("FORCE_ENRICH", "false").lower() == "true"
|
||||
|
||||
with open(INVENTORY_PATH, "r") as f:
|
||||
inventory = yaml.safe_load(f)
|
||||
inventory = load_inventory()
|
||||
|
||||
video_urls = [u for u, e in inventory.items() if e.get("is_featured_video")]
|
||||
|
||||
@@ -99,8 +99,7 @@ async def main():
|
||||
await asyncio.gather(*batch)
|
||||
|
||||
# Incremental Persistence: Save after each batch
|
||||
with open(INVENTORY_PATH, "w") as f:
|
||||
yaml.dump(inventory, f, sort_keys=False, allow_unicode=True)
|
||||
save_inventory(inventory)
|
||||
log_event(f" [💾] Saved progress: {min(i + batch_size, len(tasks))}/{len(tasks)} videos.")
|
||||
|
||||
if i + batch_size < len(tasks):
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config import GH_TOKEN, MADRID_TZ
|
||||
from src.logger import log_event
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
CNCF_LANDSCAPE_URL = "https://landscape.cncf.io/api/items"
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
GITHUB_RATE_DELAY = 0.75 # seconds between GitHub API calls to stay under 5000/hr
|
||||
MAX_REPOS_DEFAULT = 500
|
||||
ACTIVITY_STALENESS_DAYS = 30
|
||||
|
||||
# Community health thresholds
|
||||
HEALTH_ACTIVE = 50
|
||||
HEALTH_HEALTHY = 10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _github_headers() -> Dict[str, str]:
|
||||
"""Build GitHub API request headers with optional auth."""
|
||||
headers = {"Accept": "application/vnd.github.v3+json"}
|
||||
if GH_TOKEN:
|
||||
headers["Authorization"] = f"token {GH_TOKEN}"
|
||||
return headers
|
||||
|
||||
|
||||
def _parse_github_repo(url: str) -> Optional[str]:
|
||||
"""Extract 'owner/repo' from a GitHub URL. Returns None if not a GitHub URL."""
|
||||
match = re.search(r"github\.com/([^/]+/[^/]+)", url)
|
||||
if not match:
|
||||
return None
|
||||
repo = match.group(1).split("#")[0].split("?")[0].rstrip("/")
|
||||
# Strip trailing .git if present
|
||||
if repo.endswith(".git"):
|
||||
repo = repo[:-4]
|
||||
return repo
|
||||
|
||||
|
||||
def _normalize_repo_url(url: str) -> str:
|
||||
"""Normalize a GitHub repo URL for comparison (lowercase, no trailing slash)."""
|
||||
url = url.lower().rstrip("/")
|
||||
if url.endswith(".git"):
|
||||
url = url[:-4]
|
||||
# Strip protocol
|
||||
url = re.sub(r"^https?://", "", url)
|
||||
return url
|
||||
|
||||
|
||||
def _is_activity_stale(entry: Dict) -> bool:
|
||||
"""Check whether an entry's activity data is older than ACTIVITY_STALENESS_DAYS."""
|
||||
checked = entry.get("gh_activity_checked")
|
||||
if not checked:
|
||||
return True
|
||||
try:
|
||||
checked_dt = datetime.fromisoformat(checked)
|
||||
return datetime.now(MADRID_TZ) - checked_dt > timedelta(days=ACTIVITY_STALENESS_DAYS)
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module 1: CNCF Landscape Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def fetch_cncf_landscape() -> Dict[str, str]:
|
||||
"""Fetch CNCF project graduation status.
|
||||
|
||||
Returns dict mapping repo_url (normalized) -> maturity
|
||||
("sandbox" | "incubating" | "graduated" | "archived").
|
||||
"""
|
||||
result: Dict[str, str] = {}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
resp = await client.get(CNCF_LANDSCAPE_URL)
|
||||
resp.raise_for_status()
|
||||
items = resp.json()
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] Failed to fetch CNCF landscape: {str(e)[:200]}")
|
||||
return result
|
||||
|
||||
if not isinstance(items, list):
|
||||
log_event("[WARN] CNCF landscape response is not a list; skipping")
|
||||
return result
|
||||
|
||||
for item in items:
|
||||
repo_url = item.get("repo_url") or ""
|
||||
project = item.get("project")
|
||||
if not repo_url or not project:
|
||||
continue
|
||||
maturity = project if isinstance(project, str) else project.get("maturity", "")
|
||||
if not maturity:
|
||||
continue
|
||||
maturity = maturity.lower()
|
||||
if maturity not in ("sandbox", "incubating", "graduated", "archived"):
|
||||
continue
|
||||
normalized = _normalize_repo_url(repo_url)
|
||||
result[normalized] = maturity
|
||||
|
||||
log_event(f"[CNCF] Fetched {len(result)} projects from CNCF landscape")
|
||||
return result
|
||||
|
||||
|
||||
async def enrich_cncf_status(inventory: Dict) -> int:
|
||||
"""Update inventory entries with cncf_status field.
|
||||
|
||||
If an entry maps to a CNCF-graduated project and lacks the
|
||||
``[DE FACTO STANDARD]`` tag, the tag is appended.
|
||||
|
||||
Returns count of entries updated.
|
||||
"""
|
||||
landscape = await fetch_cncf_landscape()
|
||||
if not landscape:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
for url, entry in inventory.items():
|
||||
if url.startswith("INTRO:"):
|
||||
continue
|
||||
normalized = _normalize_repo_url(url)
|
||||
maturity = landscape.get(normalized)
|
||||
if not maturity:
|
||||
continue
|
||||
|
||||
old_status = entry.get("cncf_status")
|
||||
entry["cncf_status"] = maturity
|
||||
if old_status != maturity:
|
||||
updated += 1
|
||||
|
||||
# Auto-tag graduated projects
|
||||
if maturity == "graduated":
|
||||
tags = entry.get("tags")
|
||||
if isinstance(tags, list) and "[DE FACTO STANDARD]" not in tags:
|
||||
tags.append("[DE FACTO STANDARD]")
|
||||
entry["tags"] = tags
|
||||
|
||||
log_event(f"[CNCF] Enriched {updated} inventory entries with CNCF status")
|
||||
return updated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module 2: Social Signal Enrichment (GitHub Activity)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fetch_repo_activity(
|
||||
client: httpx.AsyncClient,
|
||||
owner_repo: str,
|
||||
) -> Tuple[int, int]:
|
||||
"""Fetch open issue count and recent open PR count for a single repo.
|
||||
|
||||
Returns (open_issues_count, open_prs_count).
|
||||
"""
|
||||
headers = _github_headers()
|
||||
open_issues = 0
|
||||
open_prs = 0
|
||||
|
||||
# Fetch repo-level stats (open_issues_count includes PRs on GitHub)
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{GITHUB_API_BASE}/repos/{owner_repo}",
|
||||
headers=headers,
|
||||
timeout=15.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
open_issues = data.get("open_issues_count", 0)
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] GitHub repo fetch failed for {owner_repo}: {str(e)[:120]}")
|
||||
|
||||
await asyncio.sleep(GITHUB_RATE_DELAY)
|
||||
|
||||
# Fetch open PRs (page 1 only — we use total_count from search or headers)
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{GITHUB_API_BASE}/repos/{owner_repo}/pulls",
|
||||
headers=headers,
|
||||
params={"state": "open", "sort": "created", "per_page": 1},
|
||||
timeout=15.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
# The total count of open PRs is not directly in the body for the
|
||||
# list endpoint, but we can parse the Link header for the last page.
|
||||
# As a simpler approach, the body is a list; if it has items the repo
|
||||
# has open PRs. We use the repo-level open_issues_count as the
|
||||
# combined metric (GitHub counts PRs as issues).
|
||||
pr_data = resp.json()
|
||||
if isinstance(pr_data, list) and len(pr_data) > 0:
|
||||
# Parse Link header for total pages
|
||||
link_header = resp.headers.get("Link", "")
|
||||
last_match = re.search(r'page=(\d+)>;\s*rel="last"', link_header)
|
||||
open_prs = int(last_match.group(1)) if last_match else len(pr_data)
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] GitHub PRs fetch failed for {owner_repo}: {str(e)[:120]}")
|
||||
|
||||
await asyncio.sleep(GITHUB_RATE_DELAY)
|
||||
|
||||
return open_issues, open_prs
|
||||
|
||||
|
||||
def _classify_health(total_activity: int) -> str:
|
||||
"""Classify community health based on combined issue + PR activity."""
|
||||
if total_activity > HEALTH_ACTIVE:
|
||||
return "active"
|
||||
if total_activity >= HEALTH_HEALTHY:
|
||||
return "healthy"
|
||||
if total_activity > 0:
|
||||
return "low"
|
||||
return "dormant"
|
||||
|
||||
|
||||
async def enrich_github_activity(inventory: Dict, max_repos: int = MAX_REPOS_DEFAULT) -> int:
|
||||
"""Fetch recent issue/PR velocity for GitHub repos in the inventory.
|
||||
|
||||
Adds fields:
|
||||
- ``gh_open_issues_30d``
|
||||
- ``gh_open_prs_30d``
|
||||
- ``gh_community_health`` ("active" | "healthy" | "low" | "dormant")
|
||||
- ``gh_activity_checked`` (ISO timestamp)
|
||||
|
||||
Skips entries whose ``gh_activity_checked`` is less than 30 days old.
|
||||
Processes at most *max_repos* repos per run.
|
||||
|
||||
Returns count of entries enriched.
|
||||
"""
|
||||
candidates: List[Tuple[str, str]] = [] # (url, owner/repo)
|
||||
|
||||
for url, entry in inventory.items():
|
||||
if url.startswith("INTRO:"):
|
||||
continue
|
||||
repo = _parse_github_repo(url)
|
||||
if not repo:
|
||||
continue
|
||||
if not _is_activity_stale(entry):
|
||||
continue
|
||||
candidates.append((url, repo))
|
||||
if len(candidates) >= max_repos:
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
log_event("[Activity] No GitHub repos require activity enrichment")
|
||||
return 0
|
||||
|
||||
log_event(f"[Activity] Enriching {len(candidates)} GitHub repos (max {max_repos})")
|
||||
|
||||
enriched = 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
for url, owner_repo in candidates:
|
||||
try:
|
||||
open_issues, open_prs = await _fetch_repo_activity(client, owner_repo)
|
||||
total = open_issues + open_prs
|
||||
health = _classify_health(total)
|
||||
|
||||
entry = inventory[url]
|
||||
entry["gh_open_issues_30d"] = open_issues
|
||||
entry["gh_open_prs_30d"] = open_prs
|
||||
entry["gh_community_health"] = health
|
||||
entry["gh_activity_checked"] = datetime.now(MADRID_TZ).isoformat()
|
||||
enriched += 1
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] Activity enrichment failed for {owner_repo}: {str(e)[:150]}")
|
||||
|
||||
log_event(f"[Activity] Enriched {enriched}/{len(candidates)} repos with community health data")
|
||||
return enriched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module 3: License Change Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fetch_current_license(
|
||||
client: httpx.AsyncClient,
|
||||
owner_repo: str,
|
||||
) -> Optional[str]:
|
||||
"""Fetch the current SPDX license identifier for a GitHub repo."""
|
||||
headers = _github_headers()
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{GITHUB_API_BASE}/repos/{owner_repo}",
|
||||
headers=headers,
|
||||
timeout=15.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
lic = data.get("license")
|
||||
if isinstance(lic, dict):
|
||||
return lic.get("spdx_id", "N/A")
|
||||
return "N/A"
|
||||
elif resp.status_code == 404:
|
||||
return None # repo not found / deleted
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] License fetch failed for {owner_repo}: {str(e)[:120]}")
|
||||
return None
|
||||
|
||||
|
||||
async def detect_license_changes(inventory: Dict) -> List[Dict]:
|
||||
"""Compare current gh_license with stored license, flag changes.
|
||||
|
||||
For each GitHub repo in inventory that has ``gh_license``:
|
||||
- Fetches the current license from the GitHub API.
|
||||
- Compares with the stored ``gh_license``.
|
||||
- If different, updates the entry: sets ``gh_license`` to the new value
|
||||
and stores the old value in ``gh_license_previous``.
|
||||
|
||||
Returns list of ``{url, old_license, new_license, title}`` dicts.
|
||||
"""
|
||||
candidates: List[Tuple[str, str, Dict]] = [] # (url, owner/repo, entry)
|
||||
|
||||
for url, entry in inventory.items():
|
||||
if url.startswith("INTRO:"):
|
||||
continue
|
||||
if not entry.get("gh_license") or entry["gh_license"] == "N/A":
|
||||
continue
|
||||
repo = _parse_github_repo(url)
|
||||
if not repo:
|
||||
continue
|
||||
candidates.append((url, repo, entry))
|
||||
|
||||
if not candidates:
|
||||
log_event("[License] No repos with stored licenses to check")
|
||||
return []
|
||||
|
||||
log_event(f"[License] Checking {len(candidates)} repos for license changes")
|
||||
|
||||
changes: List[Dict] = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
for url, owner_repo, entry in candidates:
|
||||
try:
|
||||
current = await _fetch_current_license(client, owner_repo)
|
||||
if current is None:
|
||||
# Repo not found or API error — skip
|
||||
continue
|
||||
|
||||
stored = entry.get("gh_license", "N/A")
|
||||
if current != stored:
|
||||
change_record = {
|
||||
"url": url,
|
||||
"old_license": stored,
|
||||
"new_license": current,
|
||||
"title": entry.get("title", url),
|
||||
}
|
||||
changes.append(change_record)
|
||||
entry["gh_license_previous"] = stored
|
||||
entry["gh_license"] = current
|
||||
log_event(
|
||||
f"[License] Change detected: {owner_repo} "
|
||||
f"{stored} -> {current}"
|
||||
)
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] License check failed for {owner_repo}: {str(e)[:150]}")
|
||||
|
||||
await asyncio.sleep(GITHUB_RATE_DELAY)
|
||||
|
||||
log_event(f"[License] Detected {len(changes)} license change(s)")
|
||||
return changes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full Enrichment Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def run_enrichment():
|
||||
"""Full enrichment pipeline: CNCF + GitHub activity + license detection."""
|
||||
from src.inventory_manager import load_inventory, save_inventory
|
||||
|
||||
log_event("ENRICHMENT PIPELINE STARTING", section_break=True)
|
||||
inventory = load_inventory()
|
||||
log_event(f"[*] Loaded {len(inventory)} inventory entries")
|
||||
|
||||
cncf_count = await enrich_cncf_status(inventory)
|
||||
activity_count = await enrich_github_activity(inventory)
|
||||
license_changes = await detect_license_changes(inventory)
|
||||
|
||||
save_inventory(inventory)
|
||||
log_event(
|
||||
f"Enrichment complete: {cncf_count} CNCF, "
|
||||
f"{activity_count} activity, "
|
||||
f"{len(license_changes)} license changes",
|
||||
section_break=True,
|
||||
)
|
||||
|
||||
return license_changes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_enrichment())
|
||||
+3
-2
@@ -94,10 +94,11 @@ async def fetch_github_metadata(client: httpx.AsyncClient, url: str, sem: asynci
|
||||
return url, default_meta
|
||||
|
||||
|
||||
from src.inventory_manager import load_inventory
|
||||
|
||||
async def run_enrichment():
|
||||
print("[*] Loading inventory database...")
|
||||
with open(INVENTORY_PATH, "r") as f:
|
||||
inventory = yaml.safe_load(f) or {}
|
||||
inventory = load_inventory()
|
||||
|
||||
print(f"[*] Loaded {len(inventory)} total entries.")
|
||||
|
||||
|
||||
+20
-9
@@ -122,7 +122,8 @@ async def discover_optimal_models():
|
||||
if name not in all_supported: all_supported.append(name)
|
||||
elif resp.status_code == 429:
|
||||
log_event(f" [!] Discovery Key is rate-limited (429). Skipping.")
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] model discovery for key: {str(e)[:100]}")
|
||||
|
||||
if not all_supported:
|
||||
log_event(" [!] Discovery failed. Falling back to safe defaults.")
|
||||
@@ -136,7 +137,8 @@ async def discover_optimal_models():
|
||||
try:
|
||||
version = float(version_match.group(1))
|
||||
score += version * 50
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] parse model version for {name}: {str(e)[:100]}")
|
||||
if "-ultra" in name: score += 100
|
||||
elif "-pro" in name: score += 50
|
||||
elif "-flash" in name: score += 25
|
||||
@@ -169,7 +171,9 @@ class GeminiDiagnostics:
|
||||
async def resolve_url(url: str) -> str:
|
||||
shorteners = ['t.co', 'bit.ly', 'buff.ly', 'goo.gl', 'tinyurl.com', 't.ly', 'rb.gy', 'is.gd', 'drp.li', 't.me', 'lnkd.in']
|
||||
try: domain = url.split("//")[-1].split("/")[0].lower()
|
||||
except: return url
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] parse domain from URL {url[:50]}: {str(e)[:100]}")
|
||||
return url
|
||||
final_url, max_hops, current_hop = url, 5, 0
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=8) as client:
|
||||
while current_hop < max_hops:
|
||||
@@ -180,7 +184,9 @@ async def resolve_url(url: str) -> str:
|
||||
new_url = str(resp.url)
|
||||
if new_url == final_url: break
|
||||
final_url, current_hop = new_url, current_hop + 1
|
||||
except: break
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] resolve URL hop for {final_url[:50]}: {str(e)[:100]}")
|
||||
break
|
||||
|
||||
# Mandate 34: Prevent multiple trailing slashes using centralized utility
|
||||
return sanitize_trailing_slashes(final_url)
|
||||
@@ -217,7 +223,8 @@ async def get_github_activity(url: str) -> Dict:
|
||||
try:
|
||||
from src.config import GH_TOKEN
|
||||
headers = {"Authorization": f"token {GH_TOKEN}"} if GH_TOKEN else {}
|
||||
except:
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] import GH_TOKEN for GitHub activity: {str(e)[:100]}")
|
||||
headers = {}
|
||||
|
||||
try:
|
||||
@@ -232,7 +239,8 @@ async def get_github_activity(url: str) -> Dict:
|
||||
"gh_pushed": data.get("pushed_at", "N/A"),
|
||||
"gh_license": lic_id
|
||||
}
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] fetch GitHub activity for {url}: {str(e)[:100]}")
|
||||
return default_meta
|
||||
|
||||
|
||||
@@ -436,7 +444,8 @@ async def call_gemini_with_retry(prompt: str, response_format: str = "json", max
|
||||
|
||||
resp_json = {}
|
||||
try: resp_json = response.json()
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] parse Gemini response JSON: {str(e)[:100]}")
|
||||
|
||||
usage = resp_json.get("usageMetadata", {})
|
||||
SESSION_TRACKER.track_call(current_idx, model, response.status_code, usage, role=role)
|
||||
@@ -452,7 +461,8 @@ async def call_gemini_with_retry(prompt: str, response_format: str = "json", max
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
return data
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] parse Gemini content JSON for model {model}: {str(e)[:100]}")
|
||||
|
||||
# QUALITY UPGRADE: If flash failed parsing, don't give up on the key, try a Pro model
|
||||
if ("flash" in model or "lite" in model) and any("pro" in m for m in models):
|
||||
@@ -574,7 +584,8 @@ async def fetch_youtube_metadata(url: str) -> Optional[Dict]:
|
||||
try:
|
||||
transcript = YouTubeTranscriptApi.get_transcript(vid, languages=['en', 'es'])
|
||||
transcript_text = " ".join([t['text'] for t in transcript[:100]])
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] fetch YouTube transcript for {vid}: {str(e)[:100]}")
|
||||
|
||||
full_description = f"{description}\n\n[Transcript Snippet]: {transcript_text}" if transcript_text else description
|
||||
|
||||
|
||||
@@ -21,14 +21,16 @@ class RepositoryController:
|
||||
try:
|
||||
file_meta = self.repository.get_contents(file_path, ref=branch_name)
|
||||
return base64.b64decode(file_meta.content).decode("utf-8")
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to get file '{file_path}' from branch '{branch_name}': {str(e)[:100]}")
|
||||
return ""
|
||||
|
||||
def apply_historical_chunk(self, updates: dict, next_since: str) -> None:
|
||||
branch_name = "bot/historical-accumulator"
|
||||
try:
|
||||
self.repository.get_branch(branch_name)
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"[WARN] Branch '{branch_name}' not found, creating: {str(e)[:100]}")
|
||||
self._create_feature_branch(branch_name)
|
||||
|
||||
for file_path, content in updates.items():
|
||||
@@ -54,7 +56,8 @@ class RepositoryController:
|
||||
|
||||
try:
|
||||
self._create_feature_branch(branch_name)
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"[WARN] Branch creation failed, retrying with unique suffix: {str(e)[:100]}")
|
||||
branch_name = f"bot/knowledge-update-{timestamp_slug}-{id(updates)}"
|
||||
self._create_feature_branch(branch_name)
|
||||
|
||||
|
||||
@@ -88,11 +88,12 @@ class BackupDataExtractor:
|
||||
def parse_date(x):
|
||||
try:
|
||||
return datetime.strptime(x["timestamp"], '%a %b %d %H:%M:%S +0000 %Y')
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"[WARN] Date parse failed for timestamp: {str(e)[:100]}")
|
||||
return datetime.min
|
||||
results.sort(key=parse_date)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WARN] Sorting results by date failed: {str(e)[:100]}")
|
||||
|
||||
self.log_audit("Backup Ingestion", True, f"Total links extracted: {len(results)}")
|
||||
return results
|
||||
|
||||
@@ -52,8 +52,8 @@ class SocialDataExtractor:
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
import playwright_stealth
|
||||
except:
|
||||
self.log_audit("Playwright", False, "Libraries not available.")
|
||||
except Exception as e:
|
||||
self.log_audit("Playwright", False, f"Libraries not available: {str(e)[:100]}")
|
||||
return []
|
||||
|
||||
collected_tweets = {}
|
||||
@@ -74,14 +74,16 @@ class SocialDataExtractor:
|
||||
for k in ['sameSite', 'storeId', 'id']: c.pop(k, None)
|
||||
formatted.append(c)
|
||||
await context.add_cookies(formatted)
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] Failed to load Twitter cookies: {str(e)[:100]}")
|
||||
|
||||
for account in accounts:
|
||||
page = await context.new_page()
|
||||
try:
|
||||
if hasattr(playwright_stealth, 'stealth_async'): await playwright_stealth.stealth_async(page)
|
||||
elif hasattr(playwright_stealth, 'stealth'): playwright_stealth.stealth(page)
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] Playwright stealth setup failed: {str(e)[:100]}")
|
||||
|
||||
if strategy == "search":
|
||||
import urllib.parse
|
||||
|
||||
@@ -44,7 +44,8 @@ class IntelligentLinkCleaner:
|
||||
def _load_memory(self) -> Dict:
|
||||
if os.path.exists(MEMORY_FILE):
|
||||
try: return json.load(open(MEMORY_FILE, 'r'))
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load health learning memory: {str(e)[:100]}")
|
||||
return {"domains": {}, "known_soft_404_patterns": []}
|
||||
|
||||
def _save_memory(self):
|
||||
@@ -198,8 +199,10 @@ class IntelligentLinkCleaner:
|
||||
else:
|
||||
log_event(f" [✨] RESCUED: {u} -> {new_loc}")
|
||||
check_results[u] = (True, "resurrected", new_loc)
|
||||
except: pass
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] verify rescued URL {new_loc[:50]}: {str(e)[:100]}")
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] AI rescue batch: {str(e)[:100]}")
|
||||
|
||||
# 2.8. Finalize Status
|
||||
log_event("FINALIZING STATUS AND METRICS...", section_break=True)
|
||||
@@ -309,7 +312,8 @@ class IntelligentLinkCleaner:
|
||||
log_event(f" [⚖️] LICENSE ALERT: {url} -> {new_lic}")
|
||||
entry["status"] = "review_required"
|
||||
entry["gh_license"] = new_lic
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] license guard check for {url}: {str(e)[:100]}")
|
||||
|
||||
if final_url != url:
|
||||
u_p = url.split("://")[-1].rstrip("/"); f_p = final_url.split("://")[-1].rstrip("/")
|
||||
@@ -325,12 +329,15 @@ class IntelligentLinkCleaner:
|
||||
h = url.replace("/master/", "/main/")
|
||||
try:
|
||||
if (await client.get(h)).status_code < 400: return True, "healed", h
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] heal master->main for {url}: {str(e)[:100]}")
|
||||
m = re.search(r'(https?://github\.com/[^/]+/[^/]+)', url)
|
||||
if m and (await client.get(m.group(1))).status_code < 400: return True, "consolidated", m.group(1)
|
||||
return False, "404", None
|
||||
return True, f"Soft Block {resp.status_code}", None
|
||||
except: return True, "Error", None
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] URL check logic for {url}: {str(e)[:100]}")
|
||||
return True, "Error", None
|
||||
|
||||
async def prune_orphaned_metadata(self):
|
||||
valid_map = {}
|
||||
|
||||
+169
-17
@@ -1,46 +1,198 @@
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import yaml
|
||||
from typing import Dict
|
||||
|
||||
INVENTORY_PATH = "data/inventory.yaml"
|
||||
SQL_PATH = "data/inventory.sql"
|
||||
|
||||
try:
|
||||
from yaml import CSafeLoader as Loader, CSafeDumper as Dumper
|
||||
except ImportError:
|
||||
from yaml import SafeLoader as Loader, SafeDumper as Dumper
|
||||
|
||||
def load_inventory(shard_file: str = None) -> Dict:
|
||||
"""
|
||||
Loads the entire inventory from a single YAML file.
|
||||
Legacy/Fast-Track standard restored: Zero-sharding complexity.
|
||||
Loads the entire inventory.
|
||||
Option 3: Imports inventory.sql to temporary SQLite database in-memory,
|
||||
queries the database to reconstruct the Python dictionary, and returns it.
|
||||
Falls back to inventory.yaml if SQL file is not present.
|
||||
"""
|
||||
if os.path.exists(INVENTORY_PATH):
|
||||
if not os.path.exists(SQL_PATH) and os.path.exists(INVENTORY_PATH):
|
||||
try:
|
||||
with open(INVENTORY_PATH, "r") as file:
|
||||
return yaml.safe_load(file) or {}
|
||||
except: pass
|
||||
return {}
|
||||
with open(INVENTORY_PATH, "r", encoding="utf-8") as file:
|
||||
return yaml.load(file, Loader=Loader) or {}
|
||||
except Exception as e:
|
||||
pass
|
||||
return {}
|
||||
|
||||
if not os.path.exists(SQL_PATH):
|
||||
return {}
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
with open(SQL_PATH, "r", encoding="utf-8") as f:
|
||||
conn.executescript(f.read())
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
# Fallback to YAML if SQL import fails
|
||||
if os.path.exists(INVENTORY_PATH):
|
||||
try:
|
||||
with open(INVENTORY_PATH, "r", encoding="utf-8") as file:
|
||||
return yaml.load(file, Loader=Loader) or {}
|
||||
except Exception as e:
|
||||
print(f"[WARN] YAML fallback load failed: {str(e)[:100]}")
|
||||
return {}
|
||||
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT * FROM resources")
|
||||
rows = cursor.fetchall()
|
||||
col_names = [description[0] for description in cursor.description]
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return {}
|
||||
|
||||
inv = {}
|
||||
for row in rows:
|
||||
record = dict(zip(col_names, row))
|
||||
url = record.pop("url")
|
||||
|
||||
# Deserialize JSON lists/dicts
|
||||
for json_field in ["hierarchy", "tags", "v1_locations", "v2_locations", "youtube_mosaic", "extra_metadata"]:
|
||||
val = record.get(json_field)
|
||||
if val:
|
||||
try:
|
||||
record[json_field] = json.loads(val)
|
||||
except Exception as e:
|
||||
print(f"[WARN] JSON parse failed for field '{json_field}': {str(e)[:100]}")
|
||||
record[json_field] = [] if json_field not in ["youtube_mosaic", "extra_metadata"] else {}
|
||||
else:
|
||||
record[json_field] = [] if json_field not in ["youtube_mosaic", "extra_metadata"] else {}
|
||||
|
||||
# Merge extra_metadata keys back into the record dictionary
|
||||
extra = record.pop("extra_metadata", {})
|
||||
if isinstance(extra, dict):
|
||||
record.update(extra)
|
||||
|
||||
# Restore types
|
||||
if record.get("is_microservice") is not None:
|
||||
record["is_microservice"] = bool(record["is_microservice"])
|
||||
if record.get("needs_ai_refresh") is not None:
|
||||
record["needs_ai_refresh"] = bool(record["needs_ai_refresh"])
|
||||
|
||||
inv[url] = record
|
||||
|
||||
conn.close()
|
||||
return inv
|
||||
|
||||
def save_inventory(inv: Dict, shard_file: str = None):
|
||||
"""
|
||||
Saves the entire inventory to a single YAML file.
|
||||
Saves the entire inventory.
|
||||
Option 3: Creates an in-memory SQLite table, populates it,
|
||||
and exports it back to inventory.sql.
|
||||
Also dual-saves a backup to inventory.yaml using fast CDumper.
|
||||
"""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
year TEXT,
|
||||
stars INTEGER,
|
||||
ai_summary TEXT,
|
||||
language TEXT,
|
||||
resource_type TEXT,
|
||||
complexity TEXT,
|
||||
is_microservice BOOLEAN,
|
||||
status TEXT,
|
||||
addition_method TEXT,
|
||||
content_hash TEXT,
|
||||
health_score REAL,
|
||||
last_checked REAL,
|
||||
needs_ai_refresh BOOLEAN,
|
||||
discovered_at TEXT,
|
||||
last_ai_eval TEXT,
|
||||
company TEXT,
|
||||
geo_region TEXT,
|
||||
hierarchy TEXT,
|
||||
tags TEXT,
|
||||
v1_locations TEXT,
|
||||
v2_locations TEXT,
|
||||
youtube_mosaic TEXT,
|
||||
extra_metadata TEXT
|
||||
);
|
||||
""")
|
||||
|
||||
columns = [
|
||||
"url", "title", "description", "year", "stars", "ai_summary", "language",
|
||||
"resource_type", "complexity", "is_microservice", "status", "addition_method",
|
||||
"content_hash", "health_score", "last_checked", "needs_ai_refresh",
|
||||
"discovered_at", "last_ai_eval", "company", "geo_region",
|
||||
"hierarchy", "tags", "v1_locations", "v2_locations", "youtube_mosaic", "extra_metadata"
|
||||
]
|
||||
|
||||
for url, entry in inv.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
record = {col: entry.get(col) for col in columns if col not in ["hierarchy", "tags", "v1_locations", "v2_locations", "youtube_mosaic", "extra_metadata"]}
|
||||
record["url"] = url
|
||||
|
||||
# Serialize lists/dicts
|
||||
record["hierarchy"] = json.dumps(entry.get("hierarchy", []))
|
||||
record["tags"] = json.dumps(entry.get("tags", []))
|
||||
record["v1_locations"] = json.dumps(entry.get("v1_locations", []))
|
||||
record["v2_locations"] = json.dumps(entry.get("v2_locations", []))
|
||||
record["youtube_mosaic"] = json.dumps(entry.get("youtube_mosaic", {}))
|
||||
|
||||
# Pull arbitrary extra fields
|
||||
extra = {}
|
||||
for k, v in entry.items():
|
||||
if k not in columns:
|
||||
extra[k] = v
|
||||
record["extra_metadata"] = json.dumps(extra)
|
||||
|
||||
# Conversions
|
||||
record["is_microservice"] = 1 if record.get("is_microservice") else 0
|
||||
record["needs_ai_refresh"] = 1 if record.get("needs_ai_refresh") else 0
|
||||
|
||||
placeholders = ", ".join(["?"] * len(columns))
|
||||
values = [record[col] for col in columns]
|
||||
cursor.execute(f"INSERT OR REPLACE INTO resources ({', '.join(columns)}) VALUES ({placeholders})", values)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Dump to SQL
|
||||
os.makedirs(os.path.dirname(SQL_PATH), exist_ok=True)
|
||||
with open(SQL_PATH, "w", encoding="utf-8") as f:
|
||||
for line in conn.iterdump():
|
||||
f.write(f"{line}\n")
|
||||
|
||||
conn.close()
|
||||
|
||||
# Dual-Save to YAML (Fast C-Dumper)
|
||||
os.makedirs(os.path.dirname(INVENTORY_PATH), exist_ok=True)
|
||||
with open(INVENTORY_PATH, "w") as file:
|
||||
yaml.dump(inv, file, sort_keys=False, allow_unicode=True)
|
||||
with open(INVENTORY_PATH, "w", encoding="utf-8") as file:
|
||||
yaml.dump(inv, file, Dumper=Dumper, sort_keys=False, allow_unicode=True)
|
||||
|
||||
def get_shard_name(url: str) -> str:
|
||||
# Kept for backward compatibility but unused in single-file mode
|
||||
return "inventory.yaml"
|
||||
|
||||
def update_inventory_entry(inventory: Dict, norm_url: str, new_data: Dict):
|
||||
"""
|
||||
Updates an inventory entry by merging new_data with existing data,
|
||||
preserving metadata keys like 'youtube_mosaic' if they are not in new_data.
|
||||
"""
|
||||
if norm_url not in inventory:
|
||||
inventory[norm_url] = {}
|
||||
|
||||
existing = inventory[norm_url]
|
||||
if isinstance(existing, dict):
|
||||
merged = existing.copy()
|
||||
existing_discovered = existing.get("discovered_at")
|
||||
merged.update(new_data)
|
||||
if existing_discovered:
|
||||
merged["discovered_at"] = existing_discovered
|
||||
inventory[norm_url] = merged
|
||||
else:
|
||||
inventory[norm_url] = new_data
|
||||
|
||||
|
||||
+2
-2
@@ -32,5 +32,5 @@ def _write_to_file(message: str):
|
||||
os.makedirs(os.path.dirname(DEFAULT_LOG_PATH), exist_ok=True)
|
||||
with open(DEFAULT_LOG_PATH, "a") as f:
|
||||
f.write(message + "\n")
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[WARN] write to log file: {str(e)[:100]}")
|
||||
|
||||
+34
-20
@@ -4,6 +4,10 @@ import os
|
||||
import json
|
||||
import re
|
||||
import yaml
|
||||
try:
|
||||
from yaml import CSafeLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import SafeLoader as Loader
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime, timedelta
|
||||
@@ -55,7 +59,8 @@ async def master_orchestrator():
|
||||
since_date = until_date - timedelta(days=days)
|
||||
log_event(f"[*] Mode: Relative range (Last {days} days) -> {since_date.date()}")
|
||||
is_historical = False # Force normal mode for relative range
|
||||
except:
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] parse CURATION_DAYS_BACK: {str(e)[:100]}")
|
||||
since_date = get_last_date()
|
||||
elif is_historical:
|
||||
# DEFAULT START DATE: 2026-05-15 (as requested)
|
||||
@@ -82,7 +87,8 @@ async def master_orchestrator():
|
||||
try:
|
||||
since_date = datetime.fromisoformat(env_start).replace(tzinfo=MADRID_TZ)
|
||||
log_event(f"[*] Normal Mode: From manual workflow date {since_date.date()}")
|
||||
except:
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] parse CURATION_START_DATE: {str(e)[:100]}")
|
||||
since_date = get_last_date()
|
||||
log_event(f"[*] Normal Mode: Error parsing manual date, using state.json {since_date.date()}")
|
||||
else:
|
||||
@@ -114,8 +120,8 @@ async def master_orchestrator():
|
||||
|
||||
if os.path.exists(sources_file):
|
||||
try:
|
||||
with open(sources_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
with open(sources_file, 'r', encoding='utf-8') as f:
|
||||
data = yaml.load(f, Loader=Loader)
|
||||
all_accounts = set()
|
||||
for topic_data in data.get("sources", []):
|
||||
topic_name = topic_data.get("topic")
|
||||
@@ -206,11 +212,12 @@ async def master_orchestrator():
|
||||
parsed = urlparse(expanded_url)
|
||||
domain = parsed.netloc.lower()
|
||||
|
||||
domain_info = health_learning["domains"].setdefault(domain, {"attempts": 0, "failures": 0, "consecutive_failures": 0})
|
||||
domain_info = health_learning["domains"].setdefault(domain, {"attempts": 0, "failures": 0, "consecutive_failures": 0, "success_rate": 100.0})
|
||||
consecutive_failures = domain_info.get("consecutive_failures", 0)
|
||||
success_rate = domain_info.get("success_rate", 100.0)
|
||||
|
||||
timeout_val = 12.0
|
||||
if consecutive_failures >= 3:
|
||||
if consecutive_failures >= 3 or success_rate < 50.0:
|
||||
timeout_val = 3.0
|
||||
ua = fallback_user_agents[idx % len(fallback_user_agents)]
|
||||
else:
|
||||
@@ -226,18 +233,21 @@ async def master_orchestrator():
|
||||
resp = await client.get(expanded_url)
|
||||
if resp.status_code == 404:
|
||||
asset["health"] = "dead" # Definitively dead
|
||||
info = health_learning["domains"][domain]
|
||||
info["failures"] = info.get("failures", 0) + 1
|
||||
info["consecutive_failures"] = info.get("consecutive_failures", 0) + 1
|
||||
domain_info["failures"] = domain_info.get("failures", 0) + 1
|
||||
domain_info["consecutive_failures"] = domain_info.get("consecutive_failures", 0) + 1
|
||||
else:
|
||||
asset["health"] = "online"
|
||||
info = health_learning["domains"][domain]
|
||||
info["consecutive_failures"] = 0
|
||||
except:
|
||||
domain_info["consecutive_failures"] = 0
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] health check for {expanded_url}: {str(e)[:100]}")
|
||||
asset["health"] = "timeout" # Assume alive but unreachable for now
|
||||
info = health_learning["domains"][domain]
|
||||
info["failures"] = info.get("failures", 0) + 1
|
||||
info["consecutive_failures"] = info.get("consecutive_failures", 0) + 1
|
||||
domain_info["failures"] = domain_info.get("failures", 0) + 1
|
||||
domain_info["consecutive_failures"] = domain_info.get("consecutive_failures", 0) + 1
|
||||
|
||||
# Recalculate success rate and store it
|
||||
attempts = domain_info.get("attempts", 1)
|
||||
failures = domain_info.get("failures", 0)
|
||||
domain_info["success_rate"] = round(((attempts - failures) / attempts) * 100.0, 2)
|
||||
|
||||
# 3. GitHub Metadata Enrichment
|
||||
if "github.com" in expanded_url:
|
||||
@@ -254,7 +264,8 @@ async def master_orchestrator():
|
||||
gh_data = gh_resp.json()
|
||||
asset["gh_stars"] = gh_data.get("stargazers_count")
|
||||
asset["gh_updated"] = gh_data.get("updated_at", "").split("T")[0]
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] GitHub metadata enrichment for {expanded_url}: {str(e)[:100]}")
|
||||
|
||||
return asset
|
||||
|
||||
@@ -291,7 +302,8 @@ async def master_orchestrator():
|
||||
found = re.findall(r'\]\((https?://[^\)]+)\)', content)
|
||||
for url in found:
|
||||
existing_urls.add(url.split('#')[0].rstrip('/').lower())
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] read docs/{file} for URL extraction: {str(e)[:100]}")
|
||||
|
||||
log_event(f"[*] Global Deduplication: {len(existing_urls)} existing URLs loaded.")
|
||||
|
||||
@@ -329,12 +341,14 @@ async def master_orchestrator():
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
asset_date = datetime.strptime(ts, '%a %b %d %H:%M:%S +0000 %Y').replace(tzinfo=MADRID_TZ)
|
||||
except:
|
||||
except Exception as e:
|
||||
try: asset_date = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||
except: pass
|
||||
except Exception as e2:
|
||||
log_event(f"[WARN] parse timestamp '{ts[:30]}': {str(e2)[:100]}")
|
||||
if asset_date and asset_date > max_tweet_date:
|
||||
max_tweet_date = asset_date
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] process asset timestamp: {str(e)[:100]}")
|
||||
|
||||
assets_to_evaluate.append(asset)
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ def get_system_mandates() -> str:
|
||||
if os.path.exists(MANDATES_JSON):
|
||||
try:
|
||||
return json.load(open(MANDATES_JSON, "r")).get("system_snippet", "")
|
||||
except: return ""
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] Failed to load system mandates: {str(e)[:100]}")
|
||||
return ""
|
||||
return ""
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from src.inventory_manager import load_inventory
|
||||
from src.gemini_utils import call_gemini_with_retry
|
||||
from src.config import MADRID_TZ
|
||||
from src.logger import log_event
|
||||
|
||||
DIGEST_OUTPUT_PATH = "data/news_digest.json"
|
||||
|
||||
|
||||
class NewsDigestEngine:
|
||||
"""Generates a curated news digest by filtering inventory entries by
|
||||
recency and using Gemini AI to rank the most relevant ones per category.
|
||||
|
||||
Three time windows (3 / 6 / 12 months) are produced in a single run.
|
||||
Each window contains up to 10 AI-ranked items per digest category.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 26 Digest Categories – mapped from V2 category slugs #
|
||||
# ------------------------------------------------------------------ #
|
||||
DIGEST_CATEGORIES: Dict[str, List[str]] = {
|
||||
# --- TECH CORE (9) ---
|
||||
"Kubernetes & Orchestration": [
|
||||
"kubernetes", "kubernetes-tools", "kubernetes-tutorials",
|
||||
"kubectl-commands", "kubernetes-releases", "kubernetes-autoscaling",
|
||||
"kubernetes-operators-controllers", "kubernetes-based-devel",
|
||||
"kubernetes-alternatives", "kubernetes-client-libraries",
|
||||
"kubernetes-bigdata", "managed-kubernetes-in-public-cloud", "helm",
|
||||
],
|
||||
"Containers & Runtime": [
|
||||
"docker", "container-managers", "serverless", "noops", "registries",
|
||||
],
|
||||
"Networking & Service Mesh": [
|
||||
"networking", "kubernetes-networking", "servicemesh", "istio",
|
||||
"caching", "web-servers", "cloudflare",
|
||||
],
|
||||
"Architecture & Microservices": [
|
||||
"introduction", "faq", "cloud-arch-diagrams", "matrix-table",
|
||||
"other-awesome-lists", "about",
|
||||
],
|
||||
"Data, Messaging & Storage": [
|
||||
"databases", "nosql", "newsql", "message-queue", "crunchydata",
|
||||
"yaml", "kubernetes-storage", "kubernetes-backup-migrations",
|
||||
],
|
||||
"AI & Agents": [
|
||||
"ai", "ai-agents-mcp", "chatgpt",
|
||||
],
|
||||
"MLOps & Data Science": [
|
||||
"mlops",
|
||||
],
|
||||
"Python, Java & Developer Ecosystem": [
|
||||
"python", "golang", "java_frameworks", "java_app_servers",
|
||||
"java-and-java-performance-optimization", "javascript", "dotnet",
|
||||
"angular", "react", "web3", "api",
|
||||
"swagger-code-generator-for-rest-apis", "postman",
|
||||
"lowcode-nocode", "devel-sites", "dom", "linux-dev-env",
|
||||
"ChromeDevTools", "xamarin", "jvm-parameters-matrix-table",
|
||||
"maven-gradle", "embedded-servlet-containers", "visual-studio",
|
||||
],
|
||||
"Linux & System Foundations": [
|
||||
"linux", "git",
|
||||
],
|
||||
# --- PLATFORM & OPS (8) ---
|
||||
"Security & Compliance": [
|
||||
"securityascode", "kubernetes-security", "aws-security", "oauth",
|
||||
"devsecops",
|
||||
],
|
||||
"Infrastructure as Code": [
|
||||
"iac", "terraform", "pulumi", "crossplane", "ansible",
|
||||
"kustomize", "chef", "liquibase",
|
||||
],
|
||||
"CI/CD & GitOps": [
|
||||
"cicd", "gitops", "argo", "flux", "tekton", "jenkins",
|
||||
"jenkins-alternatives", "sonarqube", "cicd-kubernetes-plugins",
|
||||
"openshift-pipelines", "stackstorm", "keptn",
|
||||
],
|
||||
"Observability, SRE & Testing": [
|
||||
"sre", "monitoring", "prometheus", "grafana",
|
||||
"kubernetes-monitoring", "chaos-engineering", "qa",
|
||||
"test-automation-frameworks", "testops",
|
||||
"performance-testing-with-jenkins-and-jmeter",
|
||||
"kubernetes-troubleshooting",
|
||||
],
|
||||
"DevOps & Culture": [
|
||||
"devops", "devops-tools", "project-management-methodology",
|
||||
"project-management-tools",
|
||||
],
|
||||
"Platform Engineering & DevEx": [
|
||||
"developerportals", "scaffolding", "mkdocs",
|
||||
],
|
||||
"FinOps & Cloud Cost": [
|
||||
"finops", "aws-pricing",
|
||||
],
|
||||
"Certification & Training": [
|
||||
"elearning", "interview-questions", "aws-training", "cheatsheets",
|
||||
"demos",
|
||||
],
|
||||
# --- CLOUD & ENTERPRISE (5) ---
|
||||
"AWS": [
|
||||
"aws", "aws-architecture", "aws-security", "aws-networking",
|
||||
"aws-databases", "aws-storage", "aws-monitoring", "aws-iac",
|
||||
"aws-tools-scripts", "aws-messaging", "aws-data", "aws-devops",
|
||||
"aws-serverless", "aws-containers", "aws-backup",
|
||||
"aws-newfeatures", "aws-miscellaneous", "aws-spain",
|
||||
],
|
||||
"Azure": [
|
||||
"azure",
|
||||
],
|
||||
"GCP, OCI & Others": [
|
||||
"GoogleCloudPlatform", "ibm_cloud", "oraclecloud",
|
||||
"digitalocean", "scaleway", "edge-computing",
|
||||
"public-cloud-solutions",
|
||||
],
|
||||
"OpenShift / Red Hat": [
|
||||
"openshift", "ocp3", "ocp4", "openshift-pipelines", "rancher",
|
||||
],
|
||||
"Virtualization & Private Cloud": [
|
||||
"kubernetes-on-premise", "kubernetes-alternatives",
|
||||
"private-cloud-solutions",
|
||||
],
|
||||
# --- INDUSTRY / GEO (4) – resolved via geo_region, not slugs ---
|
||||
"Americas": [],
|
||||
"Europe": [],
|
||||
"España": [],
|
||||
"Asia-Pacific": [],
|
||||
}
|
||||
|
||||
GEO_CATEGORIES: Dict[str, str] = {
|
||||
"Americas": "americas",
|
||||
"Europe": "europe",
|
||||
"España": "spain",
|
||||
"Asia-Pacific": "asia_pacific",
|
||||
}
|
||||
|
||||
PERIODS: Dict[str, int] = {
|
||||
"3_months": 90,
|
||||
"6_months": 180,
|
||||
"12_months": 365,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.inventory: Dict[str, Any] = load_inventory()
|
||||
|
||||
# Reverse map: v2_category_slug -> digest_category_name
|
||||
self.category_map: Dict[str, str] = {}
|
||||
for digest_cat, slugs in self.DIGEST_CATEGORIES.items():
|
||||
for slug in slugs:
|
||||
self.category_map[slug] = digest_cat
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Classification helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _get_entry_category(self, entry: dict) -> str | None:
|
||||
"""Determine which digest category an entry belongs to.
|
||||
|
||||
Checks ``v2_locations`` paths first (more specific), then falls
|
||||
back to the ``category`` field.
|
||||
"""
|
||||
for loc in entry.get("v2_locations", []):
|
||||
slug = loc.replace(".md", "")
|
||||
if slug in self.category_map:
|
||||
return self.category_map[slug]
|
||||
cat = entry.get("category", "")
|
||||
if cat in self.category_map:
|
||||
return self.category_map[cat]
|
||||
return None
|
||||
|
||||
def _get_entry_geo(self, entry: dict) -> str | None:
|
||||
"""Return the geo digest category if ``geo_region`` matches."""
|
||||
region = entry.get("geo_region", "")
|
||||
for geo_name, geo_val in self.GEO_CATEGORIES.items():
|
||||
if region == geo_val:
|
||||
return geo_name
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_within_period(entry: dict, cutoff_iso: str) -> bool:
|
||||
"""Return *True* when the entry's ``discovered_at`` is on or after
|
||||
the ISO-formatted *cutoff_iso* string. ISO 8601 strings sort
|
||||
lexicographically so a simple ``>=`` comparison is sufficient.
|
||||
"""
|
||||
discovered = entry.get("discovered_at", "")
|
||||
if not discovered:
|
||||
return False
|
||||
try:
|
||||
return discovered >= cutoff_iso
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Prompt builder #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _build_ranking_prompt(
|
||||
category: str, entries: List[dict], period: str
|
||||
) -> str:
|
||||
"""Assemble the Gemini prompt that asks for a ranked TOP-10."""
|
||||
period_label = period.replace("_", " ")
|
||||
lines: List[str] = []
|
||||
for i, e in enumerate(entries[:50]):
|
||||
summary_fragment = (e.get("ai_summary", "") or "")[:200]
|
||||
lines.append(
|
||||
f'{i}. "{e.get("title", "Unknown")}" '
|
||||
f'({e.get("url", "")}) | '
|
||||
f'Stars: {e.get("stars", 0)} | '
|
||||
f'Year: {e.get("year", "N/A")} | '
|
||||
f'Summary: {summary_fragment}'
|
||||
)
|
||||
entries_text = "\n".join(lines)
|
||||
|
||||
return (
|
||||
"You are a Senior Technical Curator for a Cloud Native "
|
||||
"knowledge portal.\n"
|
||||
f'Given these resources discovered in the last {period_label} '
|
||||
f'for "{category}", select the TOP 10 most relevant.\n\n'
|
||||
"SCORING CRITERIA:\n"
|
||||
"- Industry Impact (30%): Does this change how teams "
|
||||
"build/operate?\n"
|
||||
"- Technical Novelty (25%): New capability, paradigm shift, "
|
||||
"major release?\n"
|
||||
"- Enterprise Adoption (20%): GA releases, production-ready?\n"
|
||||
"- Community Signal (15%): CNCF graduations, major blog posts?\n"
|
||||
"- Nubenetes Relevance (10%): Directly related to cloud native?\n\n"
|
||||
'Respond ONLY JSON: {"items": [{"idx": int, '
|
||||
'"impact": "critical|high|medium", '
|
||||
'"why": "1 sentence explaining why this matters"}]}\n\n'
|
||||
f"RESOURCES:\n{entries_text}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Star-based fallback (used when Gemini is unavailable / < 3 entries) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _fallback_items(
|
||||
entries: List[dict], cat_name: str, limit: int = 10
|
||||
) -> List[dict]:
|
||||
"""Return up to *limit* items using a deterministic star-based
|
||||
ranking (no AI call required)."""
|
||||
return [
|
||||
{
|
||||
"url": e["url"],
|
||||
"title": e.get("title", "Unknown"),
|
||||
"date": e.get("discovered_at", "")[:10],
|
||||
"stars": e.get("stars", 0),
|
||||
"impact": "high" if e.get("stars", 0) >= 4 else "medium",
|
||||
"why": (e.get("ai_summary", "") or "")[:200],
|
||||
"category": cat_name,
|
||||
}
|
||||
for e in entries[:limit]
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Core generation loop #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def generate_digest(self) -> dict:
|
||||
"""Generate the full digest for all categories and time periods.
|
||||
|
||||
Returns a nested dict::
|
||||
|
||||
{
|
||||
"3_months": { "Kubernetes & Orchestration": [...], ... },
|
||||
"6_months": { ... },
|
||||
"12_months": { ... }
|
||||
}
|
||||
"""
|
||||
digest: Dict[str, Dict[str, List[dict]]] = {}
|
||||
|
||||
for period_name, days in self.PERIODS.items():
|
||||
cutoff = (
|
||||
datetime.now(MADRID_TZ) - timedelta(days=days)
|
||||
).isoformat()
|
||||
digest[period_name] = {}
|
||||
|
||||
# Bucket entries into their digest categories
|
||||
category_pools: Dict[str, List[dict]] = {}
|
||||
for url, entry in self.inventory.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if not self._is_within_period(entry, cutoff):
|
||||
continue
|
||||
|
||||
# Tech / topic category
|
||||
cat = self._get_entry_category(entry)
|
||||
if cat:
|
||||
category_pools.setdefault(cat, []).append(
|
||||
dict(entry, url=url)
|
||||
)
|
||||
|
||||
# Geo category (entry may belong to both)
|
||||
geo = self._get_entry_geo(entry)
|
||||
if geo:
|
||||
category_pools.setdefault(geo, []).append(
|
||||
dict(entry, url=url)
|
||||
)
|
||||
|
||||
# Rank each category pool
|
||||
for cat_name, entries in category_pools.items():
|
||||
entries.sort(
|
||||
key=lambda x: (
|
||||
x.get("stars", 0),
|
||||
x.get("discovered_at", ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if len(entries) < 3:
|
||||
# Too few entries – include all without AI ranking
|
||||
digest[period_name][cat_name] = self._fallback_items(
|
||||
entries, cat_name
|
||||
)
|
||||
continue
|
||||
|
||||
# Ask Gemini to rank
|
||||
try:
|
||||
prompt = self._build_ranking_prompt(
|
||||
cat_name, entries, period_name
|
||||
)
|
||||
result = await call_gemini_with_retry(
|
||||
prompt,
|
||||
prefer_flash=True,
|
||||
role="Digest-Analyst",
|
||||
)
|
||||
|
||||
ranked: List[dict] = []
|
||||
for item in result.get("items", []):
|
||||
idx = int(item.get("idx", -1))
|
||||
if 0 <= idx < len(entries):
|
||||
e = entries[idx]
|
||||
ranked.append(
|
||||
{
|
||||
"url": e["url"],
|
||||
"title": e.get("title", "Unknown"),
|
||||
"date": e.get("discovered_at", "")[:10],
|
||||
"stars": e.get("stars", 0),
|
||||
"impact": item.get("impact", "medium"),
|
||||
"why": item.get("why", ""),
|
||||
"category": cat_name,
|
||||
}
|
||||
)
|
||||
|
||||
digest[period_name][cat_name] = ranked[:10]
|
||||
log_event(
|
||||
f" [Digest] {period_name}/{cat_name}: "
|
||||
f"{len(ranked)} items ranked"
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
log_event(
|
||||
f" [Digest WARN] {period_name}/{cat_name}: "
|
||||
f"Gemini failed ({str(exc)[:80]}), "
|
||||
"using star-based fallback"
|
||||
)
|
||||
digest[period_name][cat_name] = self._fallback_items(
|
||||
entries, cat_name
|
||||
)
|
||||
|
||||
# Respect Gemini rate limits
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return digest
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Persistence #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def save_digest(digest: dict) -> None:
|
||||
"""Serialise *digest* to ``data/news_digest.json``."""
|
||||
os.makedirs(os.path.dirname(DIGEST_OUTPUT_PATH), exist_ok=True)
|
||||
with open(DIGEST_OUTPUT_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(digest, fh, indent=2, ensure_ascii=False)
|
||||
log_event(f"[Digest] Saved to {DIGEST_OUTPUT_PATH}")
|
||||
|
||||
|
||||
# ====================================================================== #
|
||||
# CLI / CI entry point #
|
||||
# ====================================================================== #
|
||||
|
||||
|
||||
async def run_news_digest() -> None:
|
||||
"""Entry point for the CI pipeline."""
|
||||
log_event("STARTING NEWS DIGEST GENERATION", section_break=True)
|
||||
engine = NewsDigestEngine()
|
||||
digest = await engine.generate_digest()
|
||||
engine.save_digest(digest)
|
||||
|
||||
total_items = sum(
|
||||
len(items) for period in digest.values() for items in period.values()
|
||||
)
|
||||
log_event(
|
||||
f"NEWS DIGEST COMPLETE: {total_items} total items across all periods"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_news_digest())
|
||||
+2
-1
@@ -47,7 +47,8 @@ def auto_format_file(filepath: str):
|
||||
try:
|
||||
cleaned = normalize_url(url)
|
||||
return f"[{text}]({cleaned})"
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"[WARN] URL normalization failed for '{url[:60]}': {str(e)[:100]}")
|
||||
return match.group(0)
|
||||
return match.group(0)
|
||||
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
def check_file(file_path):
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
lines = content.splitlines()
|
||||
|
||||
seen_urls = set()
|
||||
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
# 1. Check section titles (H2-H6) for Emojis, Special Characters, and Ampersands (Mandate 32)
|
||||
if line.startswith("#"):
|
||||
header_level = len(line) - len(line.lstrip('#'))
|
||||
if 2 <= header_level <= 6:
|
||||
title_text = line.lstrip('#').strip()
|
||||
# Check for ampersand
|
||||
if "&" in title_text:
|
||||
errors.append(f"Line {line_num}: Title contains ampersand '&': '{title_text}' (replace with 'and')")
|
||||
|
||||
# Check for emojis or special characters
|
||||
for char in title_text:
|
||||
# Allow alphanumeric, spaces, hyphens, colons, parentheses, commas, periods, quotes
|
||||
if ord(char) > 0x2000 and ord(char) not in (0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d):
|
||||
errors.append(f"Line {line_num}: Title contains emojis or special characters: '{char}' in '{title_text}'")
|
||||
|
||||
# 2. Check for Markdown link rules (Mandate 33)
|
||||
# Match [ Link ](URL) - space at start or end of brackets
|
||||
if re.search(r'\[\s+[^\]]*\]\(', line) or re.search(r'\[[^\]]*\s+\]\(', line):
|
||||
errors.append(f"Line {line_num}: Link text contains leading or trailing spaces inside brackets: '{line.strip()}'")
|
||||
|
||||
# 3. Extract and validate URLs
|
||||
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', line)
|
||||
for link_text, url in links:
|
||||
url = url.strip()
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
if url.startswith("https:/") and not url.startswith("https://"):
|
||||
errors.append(f"Line {line_num}: Corrupted protocol prefix: '{url}'")
|
||||
elif url.startswith("http:/") and not url.startswith("http://"):
|
||||
errors.append(f"Line {line_num}: Corrupted protocol prefix: '{url}'")
|
||||
|
||||
# Check for duplicates in this category file
|
||||
if os.path.basename(file_path) not in ["index.md", "about.md"]:
|
||||
# Normalize URL to check duplicate
|
||||
clean_url = url.split("?")[0].split("#")[0].lower().rstrip('/')
|
||||
if clean_url in seen_urls:
|
||||
# Report as warning rather than blocking error
|
||||
warnings.append(f"Line {line_num}: Duplicate URL found in this file: '{url}'")
|
||||
else:
|
||||
seen_urls.add(clean_url)
|
||||
|
||||
# Check year tag if present
|
||||
match_year = re.search(r'\*\*\(([0-9]{4})\)\*\*', line)
|
||||
if match_year:
|
||||
year = int(match_year.group(1))
|
||||
if not (1990 <= year <= 2030):
|
||||
errors.append(f"Line {line_num}: Year tag '{year}' is outside reasonable bounds: '{line.strip()}'")
|
||||
|
||||
# Global multi-line check for link text line breaks
|
||||
if re.search(r'\[[^\]]*\n[^\]]*\]\(', content):
|
||||
errors.append("Global: Found a link with a line break inside the link text brackets.")
|
||||
|
||||
return errors, warnings
|
||||
|
||||
def main():
|
||||
docs_dir = "docs"
|
||||
if not os.path.exists(docs_dir):
|
||||
print(f"Directory {docs_dir} not found.")
|
||||
sys.exit(0)
|
||||
|
||||
total_errors = 0
|
||||
total_warnings = 0
|
||||
|
||||
for root, dirs, files in os.walk(docs_dir):
|
||||
if "images" in root or "static" in root:
|
||||
continue
|
||||
for file in files:
|
||||
if file.endswith(".md"):
|
||||
path = os.path.join(root, file)
|
||||
errors, warnings = check_file(path)
|
||||
if errors:
|
||||
print(f"❌ {path}:")
|
||||
for err in errors:
|
||||
print(f" {err}")
|
||||
total_errors += len(errors)
|
||||
if warnings:
|
||||
# By default do not flood output with duplicate warnings unless verbose
|
||||
if "--verbose" in sys.argv:
|
||||
print(f"⚠️ {path}:")
|
||||
for warn in warnings:
|
||||
print(f" {warn}")
|
||||
total_warnings += len(warnings)
|
||||
|
||||
print(f"\nScan complete: Found {total_errors} errors and {total_warnings} warnings in markdown files.")
|
||||
if total_errors > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All Markdown files passed the schema check successfully!")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,7 +12,9 @@ V2_DIR = "v2-docs"
|
||||
def run_command(cmd):
|
||||
try:
|
||||
return subprocess.check_output(cmd, shell=True).decode('utf-8').strip()
|
||||
except: return "0"
|
||||
except Exception as e:
|
||||
print(f"[WARN] Command '{cmd[:60]}' failed: {str(e)[:100]}")
|
||||
return "0"
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
"""Strips emojis and ampersands for README compatibility."""
|
||||
@@ -26,7 +28,8 @@ def get_stats():
|
||||
inventory = {}
|
||||
try:
|
||||
inventory = load_inventory()
|
||||
except: pass
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to load inventory: {str(e)[:100]}")
|
||||
|
||||
# 2. Basic Metrics
|
||||
total_links = len([u for u in inventory.keys() if not u.startswith("INTRO:")])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import yaml
|
||||
import os
|
||||
from src.inventory_manager import load_inventory
|
||||
|
||||
# Map category IDs to their friendly names and outline border colors (V2 only)
|
||||
CATEGORIES = {
|
||||
@@ -13,10 +14,7 @@ CATEGORIES = {
|
||||
}
|
||||
|
||||
def load_inventory_channels():
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
inventory_path = os.path.join(repo_root, 'data', 'inventory.yaml')
|
||||
with open(inventory_path, 'r', encoding='utf-8') as f:
|
||||
inventory = yaml.safe_load(f) or {}
|
||||
inventory = load_inventory()
|
||||
|
||||
channels = []
|
||||
for url, entry in inventory.items():
|
||||
|
||||
+32
-22
@@ -6,6 +6,12 @@ from datetime import datetime
|
||||
from src.logger import log_event
|
||||
from src.gemini_utils import normalize_url, clean_toc_text
|
||||
from src.config import INVENTORY_PATH
|
||||
from src.inventory_manager import load_inventory
|
||||
|
||||
try:
|
||||
from yaml import CSafeLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import SafeLoader as Loader
|
||||
|
||||
V1_DIR = "docs"
|
||||
V2_DIR = "v2-docs"
|
||||
@@ -20,19 +26,16 @@ class SafetyGuard:
|
||||
self.inventory = self._load_inventory()
|
||||
|
||||
def _load_inventory(self):
|
||||
if os.path.exists(INVENTORY_PATH):
|
||||
try:
|
||||
with open(INVENTORY_PATH, "r") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except: return {}
|
||||
return {}
|
||||
return load_inventory()
|
||||
|
||||
def _load_exempt_files(self):
|
||||
try:
|
||||
with open("data/link_rules.yaml", "r") as f:
|
||||
rules = yaml.safe_load(f)
|
||||
with open("data/link_rules.yaml", "r", encoding="utf-8") as f:
|
||||
rules = yaml.load(f, Loader=Loader)
|
||||
return rules.get("hierarchy_rules", {}).get("toc_exempt_files", [])
|
||||
except: return []
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load exempt files from link_rules.yaml: {str(e)[:100]}")
|
||||
return []
|
||||
|
||||
def validate_data_integrity(self, old_inventory: dict):
|
||||
"""Mandate 1: Información Preservation."""
|
||||
@@ -59,9 +62,11 @@ class SafetyGuard:
|
||||
"""Mandate 27: Special Assets Exhaustive Inclusion."""
|
||||
if not os.path.exists(SPECIAL_ASSETS_PATH): return
|
||||
try:
|
||||
with open(SPECIAL_ASSETS_PATH, "r") as f:
|
||||
special = yaml.safe_load(f).get("special_assets", [])
|
||||
except: return
|
||||
with open(SPECIAL_ASSETS_PATH, "r", encoding="utf-8") as f:
|
||||
special = yaml.load(f, Loader=Loader).get("special_assets", [])
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load special_assets.yaml for validation: {str(e)[:100]}")
|
||||
return
|
||||
|
||||
for sa in special:
|
||||
if "Include 100%" in sa.get("v2_rule", "") or "Exhaustive" in sa.get("v2_rule", ""):
|
||||
@@ -88,7 +93,8 @@ class SafetyGuard:
|
||||
stars = meta.get("gh_stars", meta.get("stars", 0) * 100)
|
||||
if inactive_years > 4 and stars < 30:
|
||||
self.warnings.append(f"🏚️ **MVQ Violation**: Stale repo `{url}` (>4yrs) in V2 with low impact")
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] MVQ compliance check for {url}: {str(e)[:100]}")
|
||||
|
||||
def validate_linguistic_tagging(self):
|
||||
"""Mandate 10: Explicit Language Tagging."""
|
||||
@@ -175,9 +181,11 @@ class SafetyGuard:
|
||||
"""Mandate 11: Workflow-Config Synchronization."""
|
||||
if not os.path.exists(WORKFLOW_PATH) or not os.path.exists(CURATION_SOURCES_PATH): return
|
||||
try:
|
||||
with open(CURATION_SOURCES_PATH, "r") as f:
|
||||
sources = yaml.safe_load(f).get("sources", [])
|
||||
except: return
|
||||
with open(CURATION_SOURCES_PATH, "r", encoding="utf-8") as f:
|
||||
sources = yaml.load(f, Loader=Loader).get("sources", [])
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load curation_sources.yaml for nav sync: {str(e)[:100]}")
|
||||
return
|
||||
|
||||
topics = [s["topic"] for s in sources]
|
||||
with open(WORKFLOW_PATH, "r") as f:
|
||||
@@ -191,10 +199,11 @@ class SafetyGuard:
|
||||
def validate_forbidden_tags(self):
|
||||
"""Mandate 51/Safety: Check for forbidden HTML tags in docs and v2-docs, except allowing iframes in videos."""
|
||||
try:
|
||||
with open("data/link_rules.yaml", "r") as f:
|
||||
rules = yaml.safe_load(f)
|
||||
with open("data/link_rules.yaml", "r", encoding="utf-8") as f:
|
||||
rules = yaml.load(f, Loader=Loader)
|
||||
forbidden = rules.get("safety_guard", {}).get("forbidden_tags", [])
|
||||
except:
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load forbidden tags from link_rules.yaml: {str(e)[:100]}")
|
||||
return
|
||||
|
||||
if not forbidden:
|
||||
@@ -275,9 +284,10 @@ class SafetyGuard:
|
||||
# 2. Run standard validations
|
||||
if old_inv_path and os.path.exists(old_inv_path):
|
||||
try:
|
||||
with open(old_inv_path, "r") as f:
|
||||
self.validate_data_integrity(yaml.safe_load(f) or {})
|
||||
except: pass
|
||||
with open(old_inv_path, "r", encoding="utf-8") as f:
|
||||
self.validate_data_integrity(yaml.load(f, Loader=Loader) or {})
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load old inventory for data integrity check: {str(e)[:100]}")
|
||||
|
||||
self.validate_semantic_interlinking()
|
||||
self.validate_special_assets_completeness()
|
||||
|
||||
@@ -8,7 +8,7 @@ REQUIRED_SECTIONS = [
|
||||
"3. The Agentic Stack",
|
||||
"4. The 2026 Architectural Shift",
|
||||
"5. Dual-Edition Architecture (V1 vs V2)",
|
||||
"6. The Unified Agentic Database (Knowledge Graph)",
|
||||
"6. The Unified Agentic Database (Coexistence Knowledge Graph)",
|
||||
"7. AI Economic Architecture and Cost Analysis",
|
||||
"8. The Agentic AI Engine",
|
||||
"9. GitHub Workflows and Automation",
|
||||
|
||||
@@ -6,6 +6,11 @@ from src.logger import log_event
|
||||
CURATION_SOURCES_PATH = "data/curation_sources.yaml"
|
||||
WORKFLOW_PATH = ".github/workflows/01.1.agentic_cron.yml"
|
||||
|
||||
try:
|
||||
from yaml import CSafeLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import SafeLoader as Loader
|
||||
|
||||
class WorkflowUISync:
|
||||
"""
|
||||
Automates Mandate 11: Workflow-Config Synchronization.
|
||||
@@ -16,8 +21,8 @@ class WorkflowUISync:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(CURATION_SOURCES_PATH, "r") as f:
|
||||
sources = yaml.safe_load(f).get("sources", [])
|
||||
with open(CURATION_SOURCES_PATH, "r", encoding="utf-8") as f:
|
||||
sources = yaml.load(f, Loader=Loader).get("sources", [])
|
||||
except Exception as e:
|
||||
log_event(f" [!] Error loading curation sources: {e}")
|
||||
return False
|
||||
|
||||
+43
-10
@@ -31,6 +31,27 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
tags = item.get("tags", [])
|
||||
initial_score = item.get("impact_score", item.get("stars", 3) * 20) # Fallback mapping if stars is used
|
||||
|
||||
# 0. Check cache using content hash (Recommendation #4)
|
||||
import hashlib
|
||||
raw_content = f"{title}||{desc}||{','.join(sorted(tags))}"
|
||||
content_hash = hashlib.sha256(raw_content.encode("utf-8")).hexdigest()
|
||||
|
||||
if os.path.exists(DEBATE_MEMORY_FILE):
|
||||
try:
|
||||
with open(DEBATE_MEMORY_FILE, "r") as f:
|
||||
memory_data = json.load(f)
|
||||
cached = memory_data.get("resolved_debates", {}).get(normalize_url(url))
|
||||
if cached and cached.get("content_hash") == content_hash:
|
||||
log_event(f" [⚖️] CACHE HIT: Skipping debate for '{title}'. Returning cached consensus.")
|
||||
return (
|
||||
cached["final_consensus_score"],
|
||||
cached.get("final_tags", tags),
|
||||
cached.get("refined_summary", desc),
|
||||
cached
|
||||
)
|
||||
except Exception as e:
|
||||
log_event(f" [!] Error checking debate cache: {e}")
|
||||
|
||||
log_event(f" [⚖️] DEBATE TRIGGERED: '{title}' (Initial Score: {initial_score})", section_break=False)
|
||||
|
||||
# 0. Check if mock mode is requested or required (no keys configured)
|
||||
@@ -128,7 +149,10 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
"scores": scores,
|
||||
"justifications": justifications,
|
||||
"rebuttals": debate_transcript,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"final_tags": sorted(list(final_tags)),
|
||||
"refined_summary": refined_summary,
|
||||
"content_hash": content_hash
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -136,13 +160,14 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
if os.path.exists(DEBATE_MEMORY_FILE):
|
||||
try:
|
||||
memory_data = json.load(open(DEBATE_MEMORY_FILE, "r"))
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load debate memory for mock persist: {str(e)[:100]}")
|
||||
memory_data.setdefault("resolved_debates", {})[normalize_url(url)] = debate_data
|
||||
with open(DEBATE_MEMORY_FILE, "w") as f:
|
||||
json.dump(memory_data, f, indent=2)
|
||||
except Exception as e:
|
||||
log_event(f" [!] Failed to persist debate memory: {e}")
|
||||
|
||||
|
||||
return final_score, sorted(list(final_tags)), refined_summary, debate_data
|
||||
|
||||
system_mandates = get_system_mandates()
|
||||
@@ -187,7 +212,10 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
"final_consensus_score": fast_pass_score,
|
||||
"fast_pass": True,
|
||||
"justification": fast_pass_justification,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"final_tags": fast_pass_tags,
|
||||
"refined_summary": fast_pass_summary,
|
||||
"content_hash": content_hash
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -195,13 +223,14 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
if os.path.exists(DEBATE_MEMORY_FILE):
|
||||
try:
|
||||
memory_data = json.load(open(DEBATE_MEMORY_FILE, "r"))
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load debate memory for fast-pass persist: {str(e)[:100]}")
|
||||
memory_data.setdefault("resolved_debates", {})[normalize_url(url)] = debate_data
|
||||
with open(DEBATE_MEMORY_FILE, "w") as f:
|
||||
json.dump(memory_data, f, indent=2)
|
||||
except Exception as e:
|
||||
log_event(f" [!] Failed to persist debate memory: {e}")
|
||||
|
||||
|
||||
return fast_pass_score, fast_pass_tags, fast_pass_summary, debate_data
|
||||
|
||||
log_event(f" [⚖️] Borderline score detected ({fast_pass_score}). Escalating to full Multi-Agent Debate Panel...")
|
||||
@@ -325,7 +354,10 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
"scores": scores,
|
||||
"justifications": justifications,
|
||||
"rebuttals": debate_transcript,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"final_tags": final_tags,
|
||||
"refined_summary": refined_summary,
|
||||
"content_hash": content_hash
|
||||
}
|
||||
|
||||
# Persist the resolved debate to memory log (Mandate 3.1)
|
||||
@@ -334,10 +366,11 @@ async def run_debate_protocol(item: Dict, is_new_link: bool = False) -> Tuple[in
|
||||
if os.path.exists(DEBATE_MEMORY_FILE):
|
||||
try:
|
||||
memory_data = json.load(open(DEBATE_MEMORY_FILE, "r"))
|
||||
except: pass
|
||||
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load debate memory for final persist: {str(e)[:100]}")
|
||||
|
||||
memory_data.setdefault("resolved_debates", {})[normalize_url(url)] = debate_data
|
||||
|
||||
|
||||
# Keep blacklist and other fields intact
|
||||
with open(DEBATE_MEMORY_FILE, "w") as f:
|
||||
json.dump(memory_data, f, indent=2)
|
||||
|
||||
+221
-37
@@ -4,6 +4,10 @@ import json
|
||||
import hashlib
|
||||
import asyncio
|
||||
import yaml
|
||||
try:
|
||||
from yaml import CSafeLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import SafeLoader as Loader
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Set, Any, Tuple
|
||||
@@ -37,14 +41,30 @@ class V2VisionEngine:
|
||||
"AI": ["ai", "ai-agents-mcp", "chatgpt", "mlops"],
|
||||
"Architectural Foundations": ["introduction", "faq", "kubernetes", "linux", "git", "cloud-arch-diagrams", "matrix-table", "other-awesome-lists", "about"],
|
||||
"Platform & Site Reliability": ["sre", "devops", "developerportals", "scaffolding", "finops", "chaos-engineering", "performance-testing-with-jenkins-and-jmeter", "project-management-methodology", "project-management-tools", "qa", "test-automation-frameworks", "testops"],
|
||||
"Hardened Infrastructure": ["iac", "terraform", "pulumi", "crossplane", "ansible", "securityascode", "kubernetes-security", "aws-security", "oauth", "devsecops", "kustomize", "liquibase", "chef"],
|
||||
"Cloud Providers (Hyperscalers)": ["aws", "azure", "GoogleCloudPlatform", "ibm_cloud", "oraclecloud", "digitalocean", "cloudflare", "scaleway", "managed-kubernetes-in-public-cloud", "public-cloud-solutions", "private-cloud-solutions", "edge-computing", "aws-architecture", "aws-security", "aws-networking", "aws-databases", "aws-storage", "aws-monitoring", "aws-iac", "aws-tools-scripts", "aws-messaging", "aws-data", "aws-devops", "aws-serverless", "aws-containers", "aws-backup", "aws-training", "aws-newfeatures", "aws-miscellaneous", "aws-pricing", "aws-spain"],
|
||||
"Hardened Infrastructure": ["iac", "terraform", "pulumi", "crossplane", "ansible", "securityascode", "kubernetes-security", "aws-security", "devsecops", "kustomize", "liquibase"],
|
||||
"Cloud Providers (Hyperscalers)": ["aws", "azure", "GoogleCloudPlatform", "ibm_cloud", "oraclecloud", "digitalocean", "cloudflare", "managed-kubernetes-in-public-cloud", "public-cloud-solutions", "edge-computing", "aws-architecture", "aws-security", "aws-networking", "aws-databases", "aws-storage", "aws-monitoring", "aws-iac", "aws-tools-scripts", "aws-messaging", "aws-data", "aws-devops", "aws-serverless", "aws-containers", "aws-backup", "aws-training", "aws-newfeatures", "aws-miscellaneous", "aws-pricing"],
|
||||
"Networking & Service Mesh": ["networking", "kubernetes-networking", "servicemesh", "istio", "caching", "web-servers", "cloudflare"],
|
||||
"The Container Stack": ["docker", "container-managers", "serverless", "kubernetes-autoscaling", "kubernetes-operators-controllers", "kubernetes-storage", "kubernetes-monitoring", "kubernetes-troubleshooting", "kubernetes-backup-migrations", "kubernetes-on-premise", "kubernetes-bigdata", "kubernetes-client-libraries", "kubernetes-releases", "kubernetes-based-devel", "kubernetes-alternatives", "kubectl-commands", "rancher", "openshift", "ocp3", "ocp4", "noops"],
|
||||
"Data & Advanced Analytics": ["databases", "nosql", "newsql", "message-queue", "crunchydata", "yaml", "bigdata"],
|
||||
"Engineering Pipeline": ["cicd", "gitops", "argo", "flux", "tekton", "jenkins", "jenkins-alternatives", "openshift-pipelines", "sonarqube", "registries", "keptn", "stackstorm", "cicd-kubernetes-plugins"],
|
||||
"Developer Ecosystem": ["visual-studio", "javascript", "golang", "python", "java_frameworks", "java_app_servers", "java-and-java-performance-optimization", "dotnet", "angular", "react", "web3", "api", "swagger-code-generator-for-rest-apis", "postman", "lowcode-nocode", "devel-sites", "dom", "linux-dev-env", "ChromeDevTools", "xamarin", "jvm-parameters-matrix-table", "maven-gradle", "embedded-servlet-containers"],
|
||||
"Career & Industry": ["recruitment", "hr", "finops", "freelancing", "remote-tech-jobs", "workfromhome", "interview-questions", "elearning", "digital-money", "appointment-scheduling", "newsfeeds"]
|
||||
"Data & Advanced Analytics": ["databases", "nosql", "message-queue", "crunchydata", "yaml", "bigdata"],
|
||||
"Engineering Pipeline": ["cicd", "gitops", "argo", "flux", "tekton", "jenkins", "jenkins-alternatives", "openshift-pipelines", "sonarqube", "registries", "keptn", "cicd-kubernetes-plugins"],
|
||||
"Developer Ecosystem": ["visual-studio", "javascript", "golang", "python", "java_frameworks", "java_app_servers", "java-and-java-performance-optimization", "dotnet", "angular", "web3", "api", "swagger-code-generator-for-rest-apis", "postman", "lowcode-nocode", "devel-sites", "linux-dev-env", "ChromeDevTools", "maven-gradle", "embedded-servlet-containers"],
|
||||
"Career & Industry": ["recruitment", "hr", "finops", "freelancing", "remote-tech-jobs", "workfromhome", "interview-questions", "elearning", "appointment-scheduling", "newsfeeds"]
|
||||
}
|
||||
|
||||
# Stub page merge map: content from source pages renders on target pages
|
||||
self.merge_map = {
|
||||
"jvm-parameters-matrix-table": "java-and-java-performance-optimization",
|
||||
"private-cloud-solutions": "kubernetes-on-premise",
|
||||
"stackstorm": "cicd",
|
||||
"chef": "ansible",
|
||||
"newsql": "databases",
|
||||
"scaleway": "digitalocean",
|
||||
"xamarin": "dotnet",
|
||||
"dom": "javascript",
|
||||
"react": "javascript",
|
||||
"oauth": "securityascode",
|
||||
"digital-money": "finops",
|
||||
"aws-spain": "aws",
|
||||
}
|
||||
|
||||
self.library_criteria = (
|
||||
@@ -74,15 +94,23 @@ class V2VisionEngine:
|
||||
def _load_special_assets(self) -> Dict:
|
||||
path = "data/special_assets.yaml"
|
||||
if os.path.exists(path):
|
||||
try: return yaml.safe_load(open(path, "r")) or {}
|
||||
except: return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.load(f, Loader=Loader) or {}
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load special_assets.yaml: {str(e)[:100]}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _load_link_rules(self) -> Dict:
|
||||
path = "data/link_rules.yaml"
|
||||
if os.path.exists(path):
|
||||
try: return yaml.safe_load(open(path, "r")) or {}
|
||||
except: return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.load(f, Loader=Loader) or {}
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] load link_rules.yaml: {str(e)[:100]}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _load_inventory(self) -> Dict:
|
||||
@@ -98,18 +126,20 @@ class V2VisionEngine:
|
||||
|
||||
# Mandate 30: MD039 - Global Data Sanitization (Purge all whitespace/hidden chars from titles)
|
||||
for url in list(self.inventory.keys()):
|
||||
if isinstance(self.inventory[url], dict) and "title" in self.inventory[url]:
|
||||
# Purge all known whitespace characters (standard, non-breaking, thin, etc.)
|
||||
if isinstance(self.inventory[url], dict) and self.inventory[url].get("title") is not None:
|
||||
t = self.inventory[url]["title"]
|
||||
t = re.sub(r'^[\s\u00a0\u200b\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+', '', t)
|
||||
t = re.sub(r'[\s\u00a0\u200b\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$', '', t)
|
||||
self.inventory[url]["title"] = t
|
||||
if isinstance(t, str):
|
||||
# Purge all known whitespace characters (standard, non-breaking, thin, etc.)
|
||||
t = re.sub(r'^[\s\u00a0\u200b\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+', '', t)
|
||||
t = re.sub(r'[\s\u00a0\u200b\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$', '', t)
|
||||
self.inventory[url]["title"] = t
|
||||
|
||||
# 0. Mandate Sync
|
||||
try:
|
||||
from src.mandate_ingestor import MandateIngestor
|
||||
MandateIngestor().save_system_instructions()
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] mandate sync: {str(e)[:100]}")
|
||||
|
||||
all_v1_links, mosaic_html, videos_html = await self._gather_all_v1_content()
|
||||
|
||||
@@ -139,12 +169,13 @@ class V2VisionEngine:
|
||||
|
||||
# --- SURGICAL GARBAGE COLLECTION ---
|
||||
# Track every file we generate
|
||||
generated_files = {"index.md", "audit-log.md", "videos.md", "tags.md"}
|
||||
generated_files = {"index.md", "audit-log.md", "videos.md", "tags.md", "tech-digest.md", "industry-digest.md"}
|
||||
for f_name in v2_data.keys():
|
||||
generated_files.add(f_name)
|
||||
|
||||
|
||||
await self._write_premium_files(v2_data, mosaic_html, videos_html)
|
||||
self._generate_digest_pages()
|
||||
await self._generate_global_tag_index(v2_data)
|
||||
await self._sync_enterprise_navigation(v2_data)
|
||||
|
||||
@@ -197,7 +228,11 @@ class V2VisionEngine:
|
||||
if not url.startswith(("http", "mailto", "#")):
|
||||
url = f"https://nubenetes.com/{url.replace('.md', '/')}"
|
||||
# Mandate 30: MD039 - Strip all whitespace (including non-breaking space) from link text
|
||||
all_links.append({"title": nuclear_strip(title), "url": url.strip(), "description": full_desc.strip(), "original_file": file})
|
||||
orig_file = file
|
||||
slug = file.replace(".md", "")
|
||||
if slug in self.merge_map:
|
||||
orig_file = self.merge_map[slug] + ".md"
|
||||
all_links.append({"title": nuclear_strip(title), "url": url.strip(), "description": full_desc.strip(), "original_file": orig_file})
|
||||
return all_links, mosaic_html, videos_html
|
||||
|
||||
async def _verify_link_health(self, links: List[Dict]):
|
||||
@@ -212,7 +247,11 @@ class V2VisionEngine:
|
||||
if entry.get("status") == "review_required": continue
|
||||
|
||||
if not force_full and entry.get("status") == "online":
|
||||
fast_online.append(l)
|
||||
last_checked = entry.get("last_checked", 0)
|
||||
if isinstance(last_checked, (int, float)) and (datetime.now().timestamp() - last_checked) > 30 * 86400:
|
||||
needs_check.append(l)
|
||||
else:
|
||||
fast_online.append(l)
|
||||
else:
|
||||
needs_check.append(l)
|
||||
|
||||
@@ -261,7 +300,8 @@ class V2VisionEngine:
|
||||
# Mandate 22: Update last_checked for the inventory entry
|
||||
self.inventory[normalize_url(final_url)]["last_checked"] = datetime.now().timestamp()
|
||||
return link
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] resilient link check for {url}: {str(e)[:100]}")
|
||||
return None
|
||||
|
||||
async def _evaluate_and_score_resources(self, links: List[Dict]):
|
||||
@@ -333,7 +373,15 @@ class V2VisionEngine:
|
||||
if is_special: item["is_special"] = True
|
||||
# Mandate 30: Hierarchy and AI Summaries are mandatory for ELITE AI curation.
|
||||
# Optimized Skip Logic: Only skip if we already have BOTH hierarchy and a summary.
|
||||
if ((cached.get("hierarchy") and cached.get("ai_summary")) or self.render_only) and not force_eval:
|
||||
last_eval = cached.get("last_ai_eval", "")
|
||||
eval_stale = False
|
||||
if last_eval and isinstance(last_eval, str) and len(last_eval) >= 10:
|
||||
try:
|
||||
eval_age = (datetime.now(MADRID_TZ) - datetime.fromisoformat(last_eval)).days
|
||||
eval_stale = eval_age > 180
|
||||
except Exception:
|
||||
pass
|
||||
if ((cached.get("hierarchy") and cached.get("ai_summary") and not eval_stale) or self.render_only) and not force_eval:
|
||||
if project_id not in project_registry or item.get("stars", 0) > project_registry[project_id].get("stars", 0):
|
||||
if project_id in project_registry and project_registry[project_id].get("is_special"): item["is_special"] = True
|
||||
project_registry[project_id] = item
|
||||
@@ -402,11 +450,15 @@ class V2VisionEngine:
|
||||
"resource_type": res.get("type", "Reference"), "complexity": res.get("complexity", "Intermediate"),
|
||||
"hierarchy": res.get("hierarchy", ["General"]), "tags": res.get("tags", []),
|
||||
"is_microservice": bool(res.get("is_microservice", False)),
|
||||
"status": "online", "is_special": item.get("is_special", False)
|
||||
"status": "online", "is_special": item.get("is_special", False),
|
||||
"last_ai_eval": datetime.now(MADRID_TZ).isoformat()
|
||||
}
|
||||
existing_entry = self.inventory.get(normalize_url(item["url"]), {})
|
||||
if existing_entry.get("discovered_at"):
|
||||
eval_data["discovered_at"] = existing_entry["discovered_at"]
|
||||
item.update(eval_data)
|
||||
batch_results.append(item)
|
||||
|
||||
|
||||
# Incremental Persistence
|
||||
norm_url = normalize_url(item["url"])
|
||||
from src.inventory_manager import update_inventory_entry
|
||||
@@ -488,8 +540,12 @@ class V2VisionEngine:
|
||||
"resource_type": res.get("type", "Reference"), "complexity": res.get("complexity", "Intermediate"),
|
||||
"hierarchy": res.get("hierarchy", ["General"]), "tags": res.get("tags", []),
|
||||
"is_microservice": bool(res.get("is_microservice", False)),
|
||||
"status": "online", "is_special": item.get("is_special", False)
|
||||
"status": "online", "is_special": item.get("is_special", False),
|
||||
"last_ai_eval": datetime.now(MADRID_TZ).isoformat()
|
||||
}
|
||||
existing_entry = self.inventory.get(normalize_url(item["url"]), {})
|
||||
if existing_entry.get("discovered_at"):
|
||||
eval_data["discovered_at"] = existing_entry["discovered_at"]
|
||||
item.update(eval_data)
|
||||
analyst_results.append(item)
|
||||
except Exception:
|
||||
@@ -706,9 +762,22 @@ class V2VisionEngine:
|
||||
|
||||
return v2_structure
|
||||
|
||||
def _collect_tags_from_tree(self, node: Dict) -> List[Set]:
|
||||
"""Recursively collect maturity/tech tags from a content tree for cross-referencing."""
|
||||
results = []
|
||||
if "__links__" in node:
|
||||
for link in node["__links__"]:
|
||||
tags = set(link.get("tags", []))
|
||||
if tags:
|
||||
results.append(tags)
|
||||
for key, val in node.items():
|
||||
if key != "__links__" and isinstance(val, dict):
|
||||
results.extend(self._collect_tags_from_tree(val))
|
||||
return results
|
||||
|
||||
async def _generate_comparison_table(self, links: List[Dict]) -> str:
|
||||
standard_tools = [l for l in links if l.get("stars", 0) >= 3]
|
||||
if len(standard_tools) < 5: return ""
|
||||
if len(standard_tools) < 8: return ""
|
||||
table = "\n??? abstract \"Architect's Technical Comparison Table\"\n"
|
||||
table += " | Solution | Maturity | Primary Focus | Language | Stars |\n"
|
||||
table += " | :--- | :--- | :--- | :--- | :--- |\n"
|
||||
@@ -863,10 +932,100 @@ class V2VisionEngine:
|
||||
|
||||
|
||||
|
||||
def _generate_digest_pages(self):
|
||||
"""Generate tech-digest.md and industry-digest.md from news_digest.json."""
|
||||
digest_path = "data/news_digest.json"
|
||||
if not os.path.exists(digest_path):
|
||||
log_event("[Digest] No digest data found, skipping page generation")
|
||||
return
|
||||
with open(digest_path, "r", encoding="utf-8") as f:
|
||||
digest_data = json.load(f)
|
||||
|
||||
tech_cats = [
|
||||
"Kubernetes & Orchestration", "Containers & Runtime", "Networking & Service Mesh",
|
||||
"Architecture & Microservices", "Data, Messaging & Storage", "AI & Agents",
|
||||
"MLOps & Data Science", "Python, Java & Developer Ecosystem", "Linux & System Foundations",
|
||||
"Security & Compliance", "Infrastructure as Code", "CI/CD & GitOps",
|
||||
"Observability, SRE & Testing", "DevOps & Culture", "Platform Engineering & DevEx",
|
||||
"FinOps & Cloud Cost", "Certification & Training",
|
||||
"AWS", "Azure", "GCP, OCI & Others", "OpenShift / Red Hat", "Virtualization & Private Cloud"
|
||||
]
|
||||
geo_cats = ["Americas", "Europe", "España", "Asia-Pacific"]
|
||||
period_labels = {"3_months": "Last 3 Months", "6_months": "Last 6 Months", "12_months": "Last 12 Months"}
|
||||
|
||||
def render_digest_page(title, categories, digest_data):
|
||||
md = f"# {title}\n\n"
|
||||
md += "!!! tip \"Nubenetes Intelligence Digest\"\n"
|
||||
md += " AI-curated ranking of the most impactful resources, updated monthly.\n\n"
|
||||
for period_key, period_label in period_labels.items():
|
||||
md += f'=== "{period_label}"\n\n'
|
||||
period_data = digest_data.get(period_key, {})
|
||||
for cat in categories:
|
||||
items = period_data.get(cat, [])
|
||||
if not items:
|
||||
continue
|
||||
md += f" ## {cat}\n\n"
|
||||
md += " | Date | Resource | Impact | Why It Matters |\n"
|
||||
md += " | :--- | :--- | :---: | :--- |\n"
|
||||
for item in items:
|
||||
impact_badge = {"critical": "🔴", "high": "🟡", "medium": "🔵"}.get(item.get("impact", "medium"), "🔵")
|
||||
t = nuclear_strip(item.get("title", "Unknown"))
|
||||
md += f' | {item.get("date", "")} | [{t}]({item.get("url", "#")}) | {impact_badge} {item.get("impact", "medium")} | {item.get("why", "")} |\n'
|
||||
md += "\n"
|
||||
md += "\n"
|
||||
return md
|
||||
|
||||
tech_md = render_digest_page("📊 Nubenetes Tech & Cloud Intelligence Digest", tech_cats, digest_data)
|
||||
with open(os.path.join(V2_DIR, "tech-digest.md"), "w", encoding="utf-8") as f:
|
||||
f.write(tech_md)
|
||||
|
||||
industry_md = render_digest_page("🌍 Nubenetes Industry & Geo Intelligence Digest", geo_cats, digest_data)
|
||||
with open(os.path.join(V2_DIR, "industry-digest.md"), "w", encoding="utf-8") as f:
|
||||
f.write(industry_md)
|
||||
|
||||
log_event("[Digest] Generated tech-digest.md and industry-digest.md")
|
||||
|
||||
async def _write_premium_files(self, data: Dict[str, Dict], mosaic_html: str, videos_html: str):
|
||||
# 1. Update Index with Pulse
|
||||
trending_pool = sorted([dict(meta, url=url) for url, meta in self.inventory.items() if isinstance(meta, dict) and meta.get("stars", 0) >= 4], key=lambda x: (str(x.get("year", "0000")) if str(x.get("year", "")).isdigit() else "0000", -x.get("stars", 0)), reverse=True)
|
||||
pulse_md = "## The Agentic Pulse\n" + "\n".join([f"- **({l.get('year', 'N/A')})** [**=={nuclear_strip(l['title'])}==**]({l['url'].strip()}) {'🌟'*l.get('stars',3)}" for l in trending_pool[:5]])
|
||||
# 1. Build Trending Now from digest data, or fallback to star-based pulse
|
||||
digest_data = {}
|
||||
digest_path = "data/news_digest.json"
|
||||
if os.path.exists(digest_path):
|
||||
try:
|
||||
with open(digest_path, "r", encoding="utf-8") as df:
|
||||
digest_data = json.load(df)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if digest_data and "3_months" in digest_data:
|
||||
top_items = []
|
||||
for cat_name, items in digest_data.get("3_months", {}).items():
|
||||
for item in items[:2]:
|
||||
top_items.append({**item, "digest_category": cat_name})
|
||||
top_items.sort(key=lambda x: {"critical": 3, "high": 2, "medium": 1}.get(x.get("impact", "medium"), 0), reverse=True)
|
||||
top_items = top_items[:6]
|
||||
|
||||
impact_icons = {"critical": "🔴", "high": "🟡", "medium": "🔵"}
|
||||
cards_html = '<div class="trending-section">\n<div class="trending-section__title">🔥 Trending Now — Cloud Native Intelligence</div>\n<div class="trending-grid">\n'
|
||||
for item in top_items:
|
||||
impact = item.get("impact", "medium")
|
||||
cards_html += (
|
||||
f'<div class="trending-card">\n'
|
||||
f' <div class="trending-card__impact trending-card__impact--{impact}">{impact_icons.get(impact, "🔵")} {impact.upper()}</div>\n'
|
||||
f' <div class="trending-card__category">{item.get("digest_category", "")}</div>\n'
|
||||
f' <div class="trending-card__title"><a href="{item.get("url", "#")}">{nuclear_strip(item.get("title", "Unknown"))}</a></div>\n'
|
||||
f' <div class="trending-card__meta">{item.get("date", "")} · {"🌟" * item.get("stars", 0)}</div>\n'
|
||||
f' <div class="trending-card__why">{item.get("why", "")}</div>\n'
|
||||
f'</div>\n'
|
||||
)
|
||||
cards_html += '</div>\n'
|
||||
cards_html += '<div class="digest-links">\n'
|
||||
cards_html += ' <a href="./tech-digest/" class="digest-link-card">📊 Full Tech & Cloud Digest →</a>\n'
|
||||
cards_html += ' <a href="./industry-digest/" class="digest-link-card">🌍 Industry & Geo Digest →</a>\n'
|
||||
cards_html += '</div>\n</div>\n'
|
||||
pulse_md = cards_html
|
||||
else:
|
||||
trending_pool = sorted([dict(meta, url=url) for url, meta in self.inventory.items() if isinstance(meta, dict) and meta.get("stars", 0) >= 4], key=lambda x: (str(x.get("year", "0000")) if str(x.get("year", "")).isdigit() else "0000", -x.get("stars", 0)), reverse=True)
|
||||
pulse_md = "## The Agentic Pulse\n" + "\n".join([f"- **({l.get('year', 'N/A')})** [**=={nuclear_strip(l['title'])}==**]({l['url'].strip()}) {'🌟'*l.get('stars',3)}" for l in trending_pool[:5]])
|
||||
|
||||
# Calculate coverage for the index
|
||||
total_v1 = len(self.inventory)
|
||||
@@ -902,7 +1061,7 @@ class V2VisionEngine:
|
||||
"<center markdown=\"1\">\n"
|
||||
"<div class=\"hero-showcase-wrapper\">\n"
|
||||
" <a href=\"https://www.cncf.io/certification/software-conformance\" class=\"hero-showcase-link\">\n"
|
||||
" <img src=\"images/container_with_cars_v2.png\" alt=\"container_with_cars\" class=\"hero-showcase-image\" />\n"
|
||||
" <img src=\"/images/container_with_cars_v2.png\" alt=\"container_with_cars\" class=\"hero-showcase-image\" />\n"
|
||||
" <div class=\"hero-showcase-footer\">\n"
|
||||
" <span class=\"hero-showcase-badge\">CNCF Conformance</span>\n"
|
||||
" <span class=\"hero-showcase-caption\">Standardized conformance guarantees seamless workload portability across the Cloud Native landscape.</span>\n"
|
||||
@@ -1158,10 +1317,30 @@ class V2VisionEngine:
|
||||
|
||||
md += await render_node(info["content"], -1, f_name.replace(".md", ""), used_headers, is_intro=(f_name=="introduction.md" or f_name=="about.md"))
|
||||
|
||||
# Add Semantic "See Also" ONLY ONCE at the end of the page
|
||||
related = [f"[{data[f]['title']}](./{f})" for f in data if f != f_name and data[f]["dim"] == info["dim"]]
|
||||
if related:
|
||||
md += f"\n---\n💡 **Explore Related:** {' | '.join(related[:3])}\n\n"
|
||||
# Add Semantic "See Also" — same dimension + cross-dimension by shared tags
|
||||
same_dim = [f for f in data if f != f_name and data[f]["dim"] == info["dim"]]
|
||||
cross_dim = []
|
||||
if info.get("content") and isinstance(info["content"], dict):
|
||||
page_tags = set()
|
||||
for node_links in self._collect_tags_from_tree(info["content"]):
|
||||
page_tags.update(node_links)
|
||||
if page_tags:
|
||||
for f in data:
|
||||
if f != f_name and data[f]["dim"] != info["dim"]:
|
||||
other_tags = set()
|
||||
if isinstance(data[f].get("content"), dict):
|
||||
for t in self._collect_tags_from_tree(data[f]["content"]):
|
||||
other_tags.update(t)
|
||||
if page_tags & other_tags:
|
||||
cross_dim.append(f)
|
||||
related = [f"[{data[f]['title']}](./{f})" for f in same_dim[:3]]
|
||||
cross = [f"[{data[f]['title']}](./{f})" for f in cross_dim[:2]]
|
||||
if related or cross:
|
||||
md += "\n---\n"
|
||||
if related:
|
||||
md += f"💡 **Explore Related:** {' | '.join(related)}\n\n"
|
||||
if cross:
|
||||
md += f"🔗 **See Also:** {' | '.join(cross)}\n\n"
|
||||
|
||||
# Smart Write: Only update disk if content changed
|
||||
target_path = os.path.join(V2_DIR, f_name)
|
||||
@@ -1284,10 +1463,13 @@ class V2VisionEngine:
|
||||
try:
|
||||
with open("v2-mkdocs.yml", "r") as f: content = f.read()
|
||||
nav = [
|
||||
"nav:",
|
||||
" - \"🔙 Back to V1 (Exhaustive)\": https://nubenetes.com/v1/",
|
||||
"nav:",
|
||||
" - \"🔙 Back to V1 (Exhaustive)\": https://nubenetes.com/v1/",
|
||||
" - \"The 2026 Vision\": index.md",
|
||||
" - \"Technical Tags\": tags.md",
|
||||
" - \"Intelligence Digest\":",
|
||||
" - \"Tech & Cloud Digest\": tech-digest.md",
|
||||
" - \"Industry & Geo Digest\": industry-digest.md",
|
||||
" - \"Agentic Video Hub\":",
|
||||
" - videos/index.md",
|
||||
" - \"AI Agents and MCP\": videos/ai-agents.md",
|
||||
@@ -1309,7 +1491,8 @@ class V2VisionEngine:
|
||||
nav.extend(dim_nav)
|
||||
updated = re.sub(r'nav:.*', "\n".join(nav), content, flags=re.DOTALL)
|
||||
with open("v2-mkdocs.yml", "w") as f: f.write(updated)
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] sync enterprise navigation: {str(e)[:100]}")
|
||||
|
||||
import argparse
|
||||
if __name__ == "__main__":
|
||||
@@ -1361,7 +1544,8 @@ if __name__ == "__main__":
|
||||
with open(os.path.join(V2_DIR, f), "r") as doc:
|
||||
line = doc.readline()
|
||||
if line.startswith("# "): title = line.replace("# ", "").strip()
|
||||
except: pass
|
||||
except Exception as e:
|
||||
log_event(f"[WARN] extract title from V2 file {f}: {str(e)[:100]}")
|
||||
file_list_md += f"| {i} | `{f}` | {title} |\n"
|
||||
|
||||
# 3. Decision Matrix (Maturity Audit)
|
||||
|
||||
+28
-9
@@ -78,22 +78,41 @@ def extract_youtube_id(url):
|
||||
def get_target_file(category, technology):
|
||||
cat_lower = category.lower()
|
||||
tech_lower = technology.lower()
|
||||
|
||||
if "agent" in tech_lower or "mcp" in tech_lower or "ai and future operations" in cat_lower:
|
||||
|
||||
if "agent" in tech_lower or "mcp" in tech_lower or "ai and future operations" in cat_lower or "llm" in tech_lower or "chatgpt" in tech_lower:
|
||||
return "ai-agents.md", "AI Agents and MCP"
|
||||
elif "infrastructure as code" in cat_lower or "security" in cat_lower or "observability" in cat_lower or "monitoring" in cat_lower or "devops" in tech_lower or "iac" in tech_lower or "sre" in tech_lower:
|
||||
elif "mlops" in tech_lower or "data science" in cat_lower or "machine learning" in tech_lower:
|
||||
return "ai-agents.md", "AI Agents and MCP"
|
||||
elif "security" in cat_lower or "devsecops" in tech_lower or "zero trust" in tech_lower or "vulnerability" in tech_lower:
|
||||
return "devops-iac.md", "DevOps, IaC, and SRE"
|
||||
elif "fundamentals" in cat_lower:
|
||||
elif "infrastructure as code" in cat_lower or "observability" in cat_lower or "monitoring" in cat_lower or "devops" in tech_lower or "iac" in tech_lower or "sre" in tech_lower:
|
||||
return "devops-iac.md", "DevOps, IaC, and SRE"
|
||||
elif "terraform" in tech_lower or "ansible" in tech_lower or "pulumi" in tech_lower or "crossplane" in tech_lower:
|
||||
return "devops-iac.md", "DevOps, IaC, and SRE"
|
||||
elif "gitops" in tech_lower or "argo" in tech_lower or "flux" in tech_lower or "tekton" in tech_lower or "jenkins" in tech_lower or "cicd" in tech_lower:
|
||||
return "devops-iac.md", "DevOps, IaC, and SRE"
|
||||
elif "prometheus" in tech_lower or "grafana" in tech_lower or "opentelemetry" in tech_lower or "otel" in tech_lower:
|
||||
return "devops-iac.md", "DevOps, IaC, and SRE"
|
||||
elif "finops" in tech_lower or "cost" in tech_lower or "kubecost" in tech_lower:
|
||||
return "devops-iac.md", "DevOps, IaC, and SRE"
|
||||
elif "fundamentals" in cat_lower or "certification" in tech_lower or "cka" in tech_lower or "training" in cat_lower:
|
||||
return "fundamentals.md", "Fundamentals"
|
||||
elif "aws" in tech_lower or "azure" in tech_lower or "gcp" in tech_lower or "google cloud" in tech_lower:
|
||||
return "cloud-native.md", "Cloud Native Core"
|
||||
elif "openshift" in tech_lower or "red hat" in tech_lower or "rancher" in tech_lower:
|
||||
return "cloud-native.md", "Cloud Native Core"
|
||||
elif "vmware" in tech_lower or "proxmox" in tech_lower or "virtualization" in tech_lower:
|
||||
return "cloud-native.md", "Cloud Native Core"
|
||||
elif "docker" in tech_lower or "container" in tech_lower or "podman" in tech_lower:
|
||||
return "cloud-native.md", "Cloud Native Core"
|
||||
elif "python" in tech_lower or "golang" in tech_lower or "java" in tech_lower or "javascript" in tech_lower:
|
||||
return "fundamentals.md", "Fundamentals"
|
||||
else:
|
||||
return "cloud-native.md", "Cloud Native Core"
|
||||
|
||||
def generate_v2_videos():
|
||||
if not os.path.exists(INVENTORY_PATH):
|
||||
return
|
||||
|
||||
with open(INVENTORY_PATH, "r") as f:
|
||||
inventory = yaml.safe_load(f)
|
||||
from src.inventory_manager import load_inventory
|
||||
inventory = load_inventory()
|
||||
|
||||
featured_videos = []
|
||||
for url, entry in inventory.items():
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 🌍 Nubenetes Industry & Geo Intelligence Digest
|
||||
|
||||
!!! tip "Nubenetes Intelligence Digest"
|
||||
AI-curated ranking of the most impactful resources, updated monthly.
|
||||
|
||||
!!! info "Coming Soon"
|
||||
The Industry & Geo Digest will be populated automatically when the monthly pipeline runs with Gemini AI ranking. Check back after the next curation cycle.
|
||||
|
||||
**4 geographic categories** (Americas, Europe, Spain, Asia-Pacific) will surface industry-relevant resources classified by company and region.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 📊 Nubenetes Tech & Cloud Intelligence Digest
|
||||
|
||||
!!! tip "Nubenetes Intelligence Digest"
|
||||
AI-curated ranking of the most impactful resources, updated monthly.
|
||||
|
||||
!!! info "Coming Soon"
|
||||
The Intelligence Digest will be populated automatically when the monthly pipeline runs with Gemini AI ranking. Check back after the next curation cycle.
|
||||
|
||||
**26 categories** across Tech Core, Platform & Ops, and Cloud & Enterprise dimensions will surface the most relevant resources from the last 3, 6, and 12 months.
|
||||
+46
-15
@@ -12,6 +12,7 @@ edit_uri: "edit/master/v2-docs/"
|
||||
|
||||
theme:
|
||||
name: material
|
||||
custom_dir: docs/overrides
|
||||
language: en
|
||||
favicon: images/favicon-ultra.png
|
||||
palette:
|
||||
@@ -35,6 +36,11 @@ theme:
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- navigation.indexes
|
||||
- navigation.instant
|
||||
- navigation.instant.prefetch
|
||||
- navigation.path
|
||||
- navigation.footer
|
||||
- announce.dismiss
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- search.share
|
||||
@@ -45,7 +51,7 @@ theme:
|
||||
- navigation.prune
|
||||
- toc.integrate
|
||||
|
||||
plugins:
|
||||
plugins:
|
||||
- search
|
||||
- privacy
|
||||
- social:
|
||||
@@ -55,8 +61,29 @@ theme:
|
||||
font_family: "Inter"
|
||||
custom_dir: "docs/images/"
|
||||
logo: favicon-ultra.png
|
||||
- tags:
|
||||
tags_file: tags.md
|
||||
- minify:
|
||||
minify_html: true
|
||||
- rss:
|
||||
match_path: "(tech-digest|industry-digest).*"
|
||||
date_from_meta:
|
||||
as_creation: "date"
|
||||
abstract_chars_count: 200
|
||||
- redirects:
|
||||
redirect_maps: {}
|
||||
redirect_maps:
|
||||
jvm-parameters-matrix-table.md: java-and-java-performance-optimization.md
|
||||
private-cloud-solutions.md: kubernetes-on-premise.md
|
||||
stackstorm.md: cicd.md
|
||||
chef.md: ansible.md
|
||||
newsql.md: databases.md
|
||||
scaleway.md: digitalocean.md
|
||||
xamarin.md: dotnet.md
|
||||
dom.md: javascript.md
|
||||
react.md: javascript.md
|
||||
oauth.md: securityascode.md
|
||||
digital-money.md: finops.md
|
||||
aws-spain.md: aws.md
|
||||
|
||||
extra:
|
||||
social:
|
||||
@@ -72,7 +99,7 @@ extra:
|
||||
extra_css:
|
||||
- https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap
|
||||
- static/extra.css
|
||||
- static/v2_elite.css?v=2.3.44
|
||||
- static/v2_elite.css?v=2.4.0
|
||||
|
||||
extra_javascript:
|
||||
- static/v2_filter.js
|
||||
@@ -95,6 +122,19 @@ markdown_extensions:
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- pymdownx.mark
|
||||
- pymdownx.tasklist:
|
||||
custom_checkbox: true
|
||||
- pymdownx.keys
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.smartsymbols
|
||||
- pymdownx.caret
|
||||
- pymdownx.tilde
|
||||
- tables
|
||||
- footnotes
|
||||
- abbr
|
||||
- def_list
|
||||
|
||||
nav:
|
||||
- "🔙 Back to V1 (Exhaustive)": https://nubenetes.com/v1/
|
||||
@@ -106,6 +146,9 @@ nav:
|
||||
- "DevOps, IaC, and SRE": videos/devops-iac.md
|
||||
- "Cloud Native Core": videos/cloud-native.md
|
||||
- "Fundamentals": videos/fundamentals.md
|
||||
- "Intelligence Digest":
|
||||
- "Tech & Cloud Digest": tech-digest.md
|
||||
- "Industry & Geo Digest": industry-digest.md
|
||||
- "AI":
|
||||
- "AI Agents MCP": ai-agents-mcp.md
|
||||
- "AI": ai.md
|
||||
@@ -147,14 +190,12 @@ nav:
|
||||
- "Testops": testops.md
|
||||
- "Hardened Infrastructure":
|
||||
- "Ansible": ansible.md
|
||||
- "Chef": chef.md
|
||||
- "Crossplane": crossplane.md
|
||||
- "Devsecops": devsecops.md
|
||||
- "IaC": iac.md
|
||||
- "Kubernetes Security": kubernetes-security.md
|
||||
- "Kustomize": kustomize.md
|
||||
- "Liquibase": liquibase.md
|
||||
- "Oauth": oauth.md
|
||||
- "Pulumi": pulumi.md
|
||||
- "Securityascode": securityascode.md
|
||||
- "Terraform": terraform.md
|
||||
@@ -175,7 +216,6 @@ nav:
|
||||
- "AWS Pricing": aws-pricing.md
|
||||
- "AWS Security": aws-security.md
|
||||
- "AWS Serverless": aws-serverless.md
|
||||
- "AWS Spain": aws-spain.md
|
||||
- "AWS Storage": aws-storage.md
|
||||
- "AWS Tools Scripts": aws-tools-scripts.md
|
||||
- "AWS Training": aws-training.md
|
||||
@@ -186,9 +226,7 @@ nav:
|
||||
- "Ibm_Cloud": ibm_cloud.md
|
||||
- "Managed Kubernetes In Public Cloud": managed-kubernetes-in-public-cloud.md
|
||||
- "Oraclecloud": oraclecloud.md
|
||||
- "Private Cloud Solutions": private-cloud-solutions.md
|
||||
- "Public Cloud Solutions": public-cloud-solutions.md
|
||||
- "Scaleway": scaleway.md
|
||||
- "Networking & Service Mesh":
|
||||
- "Caching": caching.md
|
||||
- "Cloudflare": cloudflare.md
|
||||
@@ -223,7 +261,6 @@ nav:
|
||||
- "Crunchydata": crunchydata.md
|
||||
- "Databases": databases.md
|
||||
- "Message Queue": message-queue.md
|
||||
- "Newsql": newsql.md
|
||||
- "NoSQL": nosql.md
|
||||
- "Yaml": yaml.md
|
||||
- "Engineering Pipeline":
|
||||
@@ -238,14 +275,12 @@ nav:
|
||||
- "Openshift Pipelines": openshift-pipelines.md
|
||||
- "Registries": registries.md
|
||||
- "Sonarqube": sonarqube.md
|
||||
- "Stackstorm": stackstorm.md
|
||||
- "Tekton": tekton.md
|
||||
- "Developer Ecosystem":
|
||||
- "Chromedevtools": ChromeDevTools.md
|
||||
- "Angular": angular.md
|
||||
- "API": api.md
|
||||
- "Devel Sites": devel-sites.md
|
||||
- "Dom": dom.md
|
||||
- "Dotnet": dotnet.md
|
||||
- "Embedded Servlet Containers": embedded-servlet-containers.md
|
||||
- "Golang": golang.md
|
||||
@@ -253,20 +288,16 @@ nav:
|
||||
- "Java_App_Servers": java_app_servers.md
|
||||
- "Java_Frameworks": java_frameworks.md
|
||||
- "Javascript": javascript.md
|
||||
- "JVM Parameters Matrix Table": jvm-parameters-matrix-table.md
|
||||
- "Linux Dev Env": linux-dev-env.md
|
||||
- "Lowcode Nocode": lowcode-nocode.md
|
||||
- "Maven Gradle": maven-gradle.md
|
||||
- "Postman": postman.md
|
||||
- "Python": python.md
|
||||
- "React": react.md
|
||||
- "Swagger Code Generator For Rest APIs": swagger-code-generator-for-rest-apis.md
|
||||
- "Visual Studio": visual-studio.md
|
||||
- "Web3": web3.md
|
||||
- "Xamarin": xamarin.md
|
||||
- "Career & Industry":
|
||||
- "Appointment Scheduling": appointment-scheduling.md
|
||||
- "Digital Money": digital-money.md
|
||||
- "Elearning": elearning.md
|
||||
- "Finops": finops.md
|
||||
- "Freelancing": freelancing.md
|
||||
|
||||
Reference in New Issue
Block a user