first commit

This commit is contained in:
2026-05-15 09:28:11 +02:00
commit ae25aae9ce
101 changed files with 62448 additions and 0 deletions

2
src/gads_v2/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
__version__ = "0.1.0"

67
src/gads_v2/cleanup.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from .config import ROOT
from .history import now_local
@dataclass(frozen=True)
class CleanupResult:
retention_days: int
scanned_files: int
deleted_files: int
deleted_bytes: int
def cleanup_old_plans(retention_days: int = 7) -> CleanupResult:
cutoff = now_local() - timedelta(days=max(retention_days, 0))
plans_root = ROOT / "clients"
scanned = 0
deleted = 0
deleted_bytes = 0
if not plans_root.exists():
return CleanupResult(retention_days, scanned, deleted, deleted_bytes)
for plans_dir in plans_root.glob("*/plans"):
if not plans_dir.is_dir():
continue
for path in plans_dir.iterdir():
if path.suffix.lower() not in {".json", ".md"}:
continue
scanned += 1
try:
modified = now_local().fromtimestamp(path.stat().st_mtime, tz=now_local().tzinfo)
except OSError:
continue
if modified >= cutoff:
continue
try:
size = path.stat().st_size
path.unlink()
except OSError:
continue
deleted += 1
deleted_bytes += size
return CleanupResult(retention_days, scanned, deleted, deleted_bytes)
def plan_retention_days(global_rules: dict, default: int = 7) -> int:
cleanup_rules = global_rules.get("plan_cleanup", {})
try:
return int(cleanup_rules.get("retention_days", default))
except (TypeError, ValueError):
return default
def print_cleanup_result(result: CleanupResult) -> None:
if result.deleted_files <= 0:
return
mb = result.deleted_bytes / 1024 / 1024
print(
"\nAutoczyszczenie planow: "
f"usunieto {result.deleted_files} plikow starszych niz {result.retention_days} dni "
f"({mb:.2f} MB)."
)

1783
src/gads_v2/cli.py Normal file

File diff suppressed because it is too large Load Diff

70
src/gads_v2/config.py Normal file
View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def load_env(path: Path | None = None) -> None:
env_path = path or ROOT / ".env"
if not env_path.exists():
return
for raw in env_path.read_text(encoding="utf-8-sig").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
@dataclass(frozen=True)
class ClientConfig:
domain: str
google_ads_customer_id: str
adspro_client_id: str | None = None
rules: dict | None = None
@property
def safe_customer_id(self) -> str:
return re.sub(r"\D", "", self.google_ads_customer_id)
def effective_rules(self, global_rules: dict, section: str) -> dict:
rules = dict(global_rules.get(section, {}))
rules.update((self.rules or {}).get(section, {}))
return rules
@dataclass(frozen=True)
class AppConfig:
clients: dict[str, ClientConfig]
global_rules: dict
def load_config(path: Path | None = None) -> AppConfig:
config_path = path or ROOT / "config" / "clients.toml"
if not config_path.exists():
example = ROOT / "config" / "clients.example.toml"
raise FileNotFoundError(
f"Brak {config_path}. Utworz ten plik na podstawie {example}."
)
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
clients = {}
for domain, row in data.get("clients", {}).items():
clients[domain] = ClientConfig(
domain=domain,
google_ads_customer_id=str(row["google_ads_customer_id"]),
adspro_client_id=str(row.get("adspro_client_id")) if row.get("adspro_client_id") else None,
rules={key: value for key, value in row.items() if isinstance(value, dict)},
)
return AppConfig(clients=clients, global_rules=data.get("global_rules", {}))
def client_dir(domain: str) -> Path:
path = ROOT / "clients" / domain
path.mkdir(parents=True, exist_ok=True)
return path

33
src/gads_v2/google_ads.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import os
from typing import Any
from google.ads.googleads.client import GoogleAdsClient
def get_google_ads_client(use_proto_plus: bool = True) -> GoogleAdsClient:
developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN") or os.environ.get(
"GOOGLE_ADS_DEVELOPER_TOKNE"
)
if not developer_token:
raise RuntimeError("Brak GOOGLE_ADS_DEVELOPER_TOKEN w .env.")
return GoogleAdsClient.load_from_dict(
{
"developer_token": developer_token,
"client_id": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
"client_secret": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
"refresh_token": os.environ["GOOGLE_ADS_OAUTH2_REFRESH_TOKEN"],
"login_customer_id": os.environ["GOOGLE_ADS_MANAGER_ACCOUNT_ID"],
"use_proto_plus": use_proto_plus,
}
)
def run_query(client: GoogleAdsClient, customer_id: str, query: str, timeout: float = 300.0) -> list[Any]:
service = client.get_service("GoogleAdsService")
rows = []
for batch in service.search_stream(customer_id=customer_id, query=query, timeout=timeout):
rows.extend(batch.results)
return rows

49
src/gads_v2/history.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from .config import client_dir
TZ = ZoneInfo("Europe/Warsaw")
def now_local() -> datetime:
return datetime.now(TZ)
def append_history(domain: str, event: dict) -> Path:
ts = now_local()
event = {"timestamp": ts.isoformat(timespec="seconds"), **event}
base = client_dir(domain) / "history"
base.mkdir(parents=True, exist_ok=True)
path = base / f"{ts.date().isoformat()}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
return path
def append_change_markdown(domain: str, title: str, rows: list[dict]) -> Path:
ts = now_local()
base = client_dir(domain) / "changes"
base.mkdir(parents=True, exist_ok=True)
path = base / f"{ts.date().isoformat()}.md"
exists = path.exists()
with path.open("a", encoding="utf-8") as f:
if not exists:
f.write(f"# Zmiany {ts.date().isoformat()}\n\n")
f.write(f"## {ts.strftime('%H:%M')} - {title}\n\n")
if not rows:
f.write("Brak wdrozonych zmian.\n\n")
return path
keys = list(rows[0].keys())
f.write("| " + " | ".join(keys) + " |\n")
f.write("| " + " | ".join(["---"] * len(keys)) + " |\n")
for row in rows:
f.write("| " + " | ".join(str(row.get(k, "")) for k in keys) + " |\n")
f.write("\n")
return path

View File

@@ -0,0 +1,2 @@
"""Local knowledge store helpers."""

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
from ..task_catalog import load_tasks
from .store import append_import_record, append_rules, ensure_knowledge_store, normalize_rule_row
DEFAULT_MODEL = "gpt-4.1-mini"
DEFAULT_MAX_RULES = 12
MAX_SOURCE_CHARS = 60000
RULE_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"rules": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"id": {"type": "string"},
"topic": {"type": "string"},
"suggested_task_ids": {
"type": "array",
"items": {"type": "string"},
},
"rule_type": {"type": "string"},
"condition": {"type": "string"},
"recommendation": {"type": "string"},
"risk": {"type": "string"},
"source": {"type": "string"},
"confidence": {"type": "string"},
"text": {"type": "string"},
},
"required": [
"id",
"topic",
"suggested_task_ids",
"rule_type",
"condition",
"recommendation",
"risk",
"source",
"confidence",
"text",
],
},
},
"notes": {"type": "string"},
},
"required": ["rules", "notes"],
}
@dataclass(frozen=True)
class ImportResult:
source_path: Path
source_name: str
model: str
rules_count: int
rule_ids: list[str]
notes: str
dry_run: bool
def import_knowledge_file(
file_path: Path,
source_name: str,
model: str | None = None,
max_rules: int = DEFAULT_MAX_RULES,
dry_run: bool = False,
) -> ImportResult:
ensure_knowledge_store()
if not file_path.exists():
raise FileNotFoundError(f"Nie znaleziono pliku wiedzy: {file_path}")
if not file_path.is_file():
raise ValueError(f"Sciezka nie jest plikiem: {file_path}")
if not source_name.strip():
raise ValueError("Podaj --source, czyli czytelna nazwe zrodla wiedzy.")
content = read_source_file(file_path)
selected_model = model or os.environ.get("KNOWLEDGE_OPENAI_MODEL") or os.environ.get("OPENAI_MODEL") or DEFAULT_MODEL
if dry_run:
return ImportResult(
source_path=file_path,
source_name=source_name,
model=selected_model,
rules_count=0,
rule_ids=[],
notes="Tryb dry-run: plik odczytany, API nie zostalo wywolane, reguly nie zostaly zapisane.",
dry_run=True,
)
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError(
"Brak OPENAI_API_KEY. Dodaj klucz do .env albo uruchom z --dry-run, "
"zeby tylko sprawdzic plik wejsciowy."
)
payload = call_openai_extractor(
api_key=api_key,
model=selected_model,
source_name=source_name,
content=content,
max_rules=max_rules,
)
raw_rules = payload.get("rules") or []
normalized_rules = []
for row in raw_rules:
normalized = normalize_rule_row({**row, "source": source_name, "task_ids": []})
normalized_rules.append(normalized)
saved_rules = append_rules(normalized_rules, source_file=str(file_path))
notes = str(payload.get("notes") or "").strip()
append_import_record(
{
"file": str(file_path),
"source": source_name,
"model": selected_model,
"rules_count": len(saved_rules),
"notes": notes,
}
)
return ImportResult(
source_path=file_path,
source_name=source_name,
model=selected_model,
rules_count=len(saved_rules),
rule_ids=[rule.id for rule in saved_rules],
notes=notes,
dry_run=False,
)
def read_source_file(path: Path) -> str:
content = path.read_text(encoding="utf-8-sig", errors="replace")
content = content.strip()
if not content:
raise ValueError(f"Plik wiedzy jest pusty: {path}")
if len(content) > MAX_SOURCE_CHARS:
return content[:MAX_SOURCE_CHARS]
return content
def call_openai_extractor(
api_key: str,
model: str,
source_name: str,
content: str,
max_rules: int,
) -> dict[str, Any]:
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
endpoint = f"{base_url}/responses"
response = requests.post(
endpoint,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"instructions": build_extractor_instructions(source_name, max_rules),
"input": build_extractor_input(source_name, content),
"text": {
"format": {
"type": "json_schema",
"name": "knowledge_rules",
"strict": True,
"schema": RULE_SCHEMA,
}
},
"max_output_tokens": 6000,
"store": False,
},
timeout=120,
)
if response.status_code >= 400:
raise RuntimeError(f"OpenAI API zwrocilo blad {response.status_code}: {response.text}")
data = response.json()
output_text = extract_output_text(data)
if not output_text:
raise RuntimeError("OpenAI API nie zwrocilo tekstu z regułami.")
try:
payload = json.loads(output_text)
except json.JSONDecodeError as exc:
raise RuntimeError(f"OpenAI API zwrocilo nieprawidlowy JSON: {exc}") from exc
if not isinstance(payload, dict):
raise RuntimeError("OpenAI API powinno zwrocic obiekt JSON.")
return payload
def extract_output_text(data: dict[str, Any]) -> str:
if isinstance(data.get("output_text"), str):
return data["output_text"]
parts: list[str] = []
for item in data.get("output", []) or []:
for content in item.get("content", []) or []:
if content.get("type") == "output_text" and isinstance(content.get("text"), str):
parts.append(content["text"])
elif isinstance(content.get("text"), str):
parts.append(content["text"])
return "\n".join(parts).strip()
def build_extractor_instructions(source_name: str, max_rules: int) -> str:
task_lines = []
for task in load_tasks():
task_lines.append(
f"- {task.id}: {task.group_name} / {task.name}. {task.description}"
)
tasks_block = "\n".join(task_lines)
return f"""
Jestes parserem wiedzy dla terminalowego narzedzia Google Ads.
Masz zamienic material zrodlowy na maksymalnie {max_rules} atomowych regul.
Zrodlo ma nazwe: {source_name}
Aktualnie istniejace task_id w narzedziu:
{tasks_block}
Wymagania:
- Pisz po polsku.
- Wszystkie pola opisowe (`condition`, `recommendation`, `risk`, `text`, `notes`) musza byc po polsku.
- Zwracaj tylko JSON zgodny ze schematem.
- Jedna regula ma opisywac jeden konkretny wniosek, warunek, ryzyko albo rekomendacje.
- Nie tworz regul ogolnikowych typu "monitoruj wyniki"; regula ma mowic co sprawdzic i dlaczego.
- suggested_task_ids wypelniaj tylko istniejacymi task_id z listy powyzej.
- suggested_task_ids ustaw tylko wtedy, gdy regula bezposrednio dotyczy danego zadania i moze byc uzyta w jego planie.
- Nie proponuj zadania tylko dlatego, ze regula dotyczy podobnego typu kampanii albo ogolnej optymalizacji.
- Jesli regula jest wazna, ale nie pasuje bezposrednio do istniejacego zadania, ustaw suggested_task_ids na [].
- Narzedzie zapisze suggested_task_ids jako propozycje. Zadanie zostanie dopisane do task_ids dopiero po akceptacji uzytkownika.
- source w kazdej regule ustaw dokladnie jako nazwe zrodla podana powyzej.
- confidence ustaw jako low, medium albo high.
- rule_type ustaw jako audit_check, recommendation, warning albo implementation_note.
- topic ustaw krotko, np. search, pmax, shopping, feed-merchant, konwersje, ga4, gtm-tracking, negative-keywords, strategie-stawek, produkty.
- id ma byc krotkim stabilnym identyfikatorem po angielsku albo bez polskich znakow.
- text ma byc samodzielnym, jednozdaniowym opisem reguly.
""".strip()
def build_extractor_input(source_name: str, content: str) -> str:
return f"""
Przetworz ponizszy material na atomowe reguly dla narzedzia Google Ads.
Zrodlo: {source_name}
MATERIAL:
{content}
""".strip()

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from collections import defaultdict
import lancedb
from .store import append_import_record, append_rules, load_rules, normalize_rule_row
DEFAULT_LEGACY_DB = Path(r"D:\google ads\lancedb")
DEFAULT_LEGACY_TABLE = "fakty"
@dataclass(frozen=True)
class LegacyImportResult:
db_path: Path
table_name: str
imported_count: int
skipped_existing_count: int
total_rows: int
def import_legacy_lancedb(
db_path: Path = DEFAULT_LEGACY_DB,
table_name: str = DEFAULT_LEGACY_TABLE,
limit: int | None = None,
) -> LegacyImportResult:
if not db_path.exists():
raise FileNotFoundError(f"Nie znaleziono starej bazy LanceDB: {db_path}")
db = lancedb.connect(str(db_path))
table_names = list(db.table_names()) if hasattr(db, "table_names") else list(db.list_tables())
if table_name not in table_names:
raise ValueError(f"Brak tabeli {table_name} w {db_path}. Dostepne tabele: {', '.join(table_names)}")
table = db.open_table(table_name)
df = table.to_pandas()
if limit is not None:
df = df.head(max(limit, 0))
existing_ids = {rule.id for rule in load_rules()}
rows: list[dict[str, Any]] = []
skipped = 0
source_file = str(db_path / f"{table_name}.lance")
occurrences: dict[str, int] = defaultdict(int)
for _, record in df.iterrows():
record_dict = record.to_dict()
old_id = str(record_dict.get("id") or "").strip()
occurrences[old_id] += 1
row = legacy_record_to_rule(record_dict, source_file, occurrences[old_id])
normalized = normalize_rule_row(row)
if normalized["id"] in existing_ids:
skipped += 1
continue
existing_ids.add(normalized["id"])
rows.append(normalized)
saved = append_rules(rows)
append_import_record(
{
"file": source_file,
"source": f"legacy_lancedb:{table_name}",
"model": "none",
"rules_count": len(saved),
"skipped_existing_count": skipped,
"notes": "Import starej tabeli LanceDB bez API i bez przypisywania regul do zadan.",
}
)
return LegacyImportResult(
db_path=db_path,
table_name=table_name,
imported_count=len(saved),
skipped_existing_count=skipped,
total_rows=len(df),
)
def legacy_record_to_rule(record: dict[str, Any], source_file: str, occurrence: int = 1) -> dict[str, Any]:
old_id = str(record.get("id") or "").strip()
fact = str(record.get("fakt") or "").strip()
topic = str(record.get("temat") or "legacy").strip()
section = str(record.get("sekcja") or "").strip()
source = str(record.get("zrodlo") or "legacy_lancedb").strip()
target_id = old_id or fact[:80] or "legacy_rule"
if occurrence > 1:
target_id = f"{target_id[:80]}__{occurrence}"
return {
"id": target_id,
"status": "active",
"topic": topic,
"task_ids": [],
"suggested_task_ids": [],
"rule_type": "implementation_note",
"condition": section,
"recommendation": fact,
"risk": "",
"source": source,
"source_file": source_file,
"confidence": "medium",
"duplicate_of": "",
"supersedes": [],
"text": fact,
}

View File

@@ -0,0 +1,620 @@
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from ..config import ROOT
KNOWLEDGE_DIR = ROOT / "knowledge"
SOURCES_DIR = KNOWLEDGE_DIR / "sources"
LANCEDB_DIR = KNOWLEDGE_DIR / "lancedb"
RULES_PATH = KNOWLEDGE_DIR / "rules.jsonl"
IMPORTS_PATH = KNOWLEDGE_DIR / "imports.jsonl"
REVIEW_STATE_PATH = KNOWLEDGE_DIR / "review_state.json"
ALLOWED_STATUSES = {"active", "draft", "archived", "duplicate"}
DEFAULT_STATUS = "active"
@dataclass(frozen=True)
class KnowledgeRule:
id: str
status: str
topic: str
task_ids: list[str]
suggested_task_ids: list[str]
rule_type: str
condition: str
recommendation: str
risk: str
source: str
source_file: str
confidence: str
created_at: str
updated_at: str
duplicate_of: str
supersedes: list[str]
text: str
raw: dict[str, Any]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "KnowledgeRule":
task_ids = normalize_task_id_list(data.get("task_ids") or [])
suggested_task_ids = normalize_task_id_list(
data.get("suggested_task_ids") or data.get("pending_task_ids") or []
)
text = str(data.get("text") or "").strip()
if not text:
text = " ".join(
str(data.get(key) or "").strip()
for key in ("condition", "recommendation", "risk")
if str(data.get(key) or "").strip()
)
return cls(
id=str(data.get("id") or "").strip(),
status=normalize_status(data.get("status")),
topic=str(data.get("topic") or "").strip(),
task_ids=task_ids,
suggested_task_ids=suggested_task_ids,
rule_type=str(data.get("rule_type") or "").strip(),
condition=str(data.get("condition") or "").strip(),
recommendation=str(data.get("recommendation") or "").strip(),
risk=str(data.get("risk") or "").strip(),
source=str(data.get("source") or "").strip(),
source_file=str(data.get("source_file") or "").strip(),
confidence=str(data.get("confidence") or "").strip(),
created_at=str(data.get("created_at") or "").strip(),
updated_at=str(data.get("updated_at") or "").strip(),
duplicate_of=str(data.get("duplicate_of") or "").strip(),
supersedes=normalize_string_list(data.get("supersedes") or []),
text=text,
raw=data,
)
@dataclass(frozen=True)
class SearchResult:
rule: KnowledgeRule
score: int
def ensure_knowledge_store() -> dict[str, Path]:
KNOWLEDGE_DIR.mkdir(parents=True, exist_ok=True)
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
LANCEDB_DIR.mkdir(parents=True, exist_ok=True)
for path in (RULES_PATH, IMPORTS_PATH):
if not path.exists():
path.write_text("", encoding="utf-8")
if not REVIEW_STATE_PATH.exists():
REVIEW_STATE_PATH.write_text("{}", encoding="utf-8")
return {
"knowledge_dir": KNOWLEDGE_DIR,
"sources_dir": SOURCES_DIR,
"lancedb_dir": LANCEDB_DIR,
"rules_path": RULES_PATH,
"imports_path": IMPORTS_PATH,
"review_state_path": REVIEW_STATE_PATH,
}
def load_rules(path: Path = RULES_PATH) -> list[KnowledgeRule]:
ensure_knowledge_store()
rows = read_rule_rows(path)
return [KnowledgeRule.from_dict(row) for row in rows]
def read_rule_rows(path: Path = RULES_PATH) -> list[dict[str, Any]]:
ensure_knowledge_store()
rows: list[dict[str, Any]] = []
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw_line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"Nieprawidlowy JSONL w {path}:{line_number}: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(f"Wiersz {path}:{line_number} nie jest obiektem JSON.")
rows.append(data)
return rows
def write_rule_rows(rows: list[dict[str, Any]], path: Path = RULES_PATH) -> None:
ensure_knowledge_store()
with path.open("w", encoding="utf-8", newline="\n") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def append_rules(
rule_rows: list[dict[str, Any]],
path: Path = RULES_PATH,
source_file: str = "",
) -> list[KnowledgeRule]:
ensure_knowledge_store()
existing_ids = {rule.id for rule in load_rules(path) if rule.id}
normalized_rows = []
for row in rule_rows:
if source_file and not row.get("source_file"):
row = {**row, "source_file": source_file}
normalized = normalize_rule_row(row)
normalized["id"] = unique_rule_id(normalized["id"], existing_ids)
existing_ids.add(normalized["id"])
normalized_rows.append(normalized)
if normalized_rows:
with path.open("a", encoding="utf-8", newline="\n") as f:
for row in normalized_rows:
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
return [KnowledgeRule.from_dict(row) for row in normalized_rows]
def append_import_record(record: dict[str, Any], path: Path = IMPORTS_PATH) -> None:
ensure_knowledge_store()
payload = {
"created_at": datetime.now().isoformat(timespec="seconds"),
**record,
}
with path.open("a", encoding="utf-8", newline="\n") as f:
f.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
def rules_for_task(task_id: str, path: Path = RULES_PATH) -> list[KnowledgeRule]:
task_id = task_id.strip()
return [
rule
for rule in load_rules(path)
if rule.status == "active" and task_id in rule.task_ids
]
def search_rules(query: str, limit: int = 10, path: Path = RULES_PATH) -> list[SearchResult]:
query_terms = tokenize(query)
if not query_terms:
return []
results: list[SearchResult] = []
for rule in load_rules(path):
if rule.status != "active":
continue
haystack = searchable_text(rule)
score = sum(haystack.count(term) for term in query_terms)
if rule.topic.casefold() in query.casefold():
score += 3
for task_id in rule.task_ids:
if task_id.casefold() in query.casefold():
score += 3
if score > 0:
results.append(SearchResult(rule=rule, score=score))
results.sort(key=lambda item: (-item.score, item.rule.topic, item.rule.id))
return results[: max(limit, 0)]
def store_summary(path: Path = RULES_PATH) -> dict[str, int]:
rules = load_rules(path)
topics = {rule.topic for rule in rules if rule.topic}
task_ids = {task_id for rule in rules for task_id in rule.task_ids}
suggested_task_ids = {
task_id
for rule in rules
for task_id in rule.suggested_task_ids
if task_id not in rule.task_ids
}
sources = {rule.source for rule in rules if rule.source}
by_status = {status: sum(1 for rule in rules if rule.status == status) for status in ALLOWED_STATUSES}
return {
"rules": len(rules),
"active_rules": by_status.get("active", 0),
"draft_rules": by_status.get("draft", 0),
"archived_rules": by_status.get("archived", 0),
"duplicate_rules": by_status.get("duplicate", 0),
"topics": len(topics),
"task_ids": len(task_ids),
"suggested_task_ids": len(suggested_task_ids),
"suggestions": sum(
1
for rule in rules
for task_id in rule.suggested_task_ids
if task_id not in rule.task_ids
),
"sources": len(sources),
}
def list_rules(
topic: str | None = None,
task_id: str | None = None,
status: str | None = None,
source: str | None = None,
path: Path = RULES_PATH,
) -> list[KnowledgeRule]:
rules = load_rules(path)
if topic:
normalized_topic = slugify(topic)
rules = [rule for rule in rules if rule.topic == normalized_topic]
if task_id:
normalized_task_id = slugify(task_id)
rules = [rule for rule in rules if normalized_task_id in rule.task_ids]
if status:
normalized_status = normalize_status(status)
rules = [rule for rule in rules if rule.status == normalized_status]
if source:
normalized_source = source.casefold()
rules = [rule for rule in rules if normalized_source in rule.source.casefold()]
return sorted(rules, key=lambda rule: (rule.topic, rule.id))
def update_rule_status(
rule_id: str,
status: str,
duplicate_of: str = "",
path: Path = RULES_PATH,
) -> KnowledgeRule:
normalized_rule_id = rule_id.strip()
normalized_status = normalize_status(status)
if normalized_status not in ALLOWED_STATUSES:
raise ValueError(f"Nieprawidlowy status: {status}")
rows = read_rule_rows(path)
updated_rule: KnowledgeRule | None = None
now = now_iso()
for row in rows:
if str(row.get("id") or "").strip() != normalized_rule_id:
continue
row["status"] = normalized_status
row["updated_at"] = now
if duplicate_of:
row["duplicate_of"] = duplicate_of.strip()
elif normalized_status != "duplicate":
row["duplicate_of"] = ""
updated_rule = KnowledgeRule.from_dict(row)
break
if not updated_rule:
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
write_rule_rows(rows, path)
return updated_rule
def tokenize(value: str) -> list[str]:
return [
token.casefold()
for token in re.findall(r"\w+", value or "", flags=re.UNICODE)
if len(token) >= 2
]
def searchable_text(rule: KnowledgeRule) -> str:
values = [
rule.id,
rule.status,
rule.topic,
" ".join(rule.task_ids),
" ".join(rule.suggested_task_ids),
rule.rule_type,
rule.condition,
rule.recommendation,
rule.risk,
rule.source,
rule.source_file,
rule.confidence,
rule.text,
]
return " ".join(values).casefold()
def normalize_rule_row(data: dict[str, Any]) -> dict[str, Any]:
task_ids = normalize_task_id_list(data.get("task_ids") or [])
suggested_task_ids = normalize_task_id_list(data.get("suggested_task_ids") or [])
created_at = clean_field(data.get("created_at")) or now_iso()
updated_at = clean_field(data.get("updated_at")) or created_at
text = clean_field(data.get("text"))
condition = clean_field(data.get("condition"))
recommendation = clean_field(data.get("recommendation"))
risk = clean_field(data.get("risk"))
if not text:
text = " ".join(item for item in [condition, recommendation, risk] if item)
row = {
"id": slugify(clean_field(data.get("id")) or text or recommendation or condition or "knowledge_rule"),
"status": normalize_status(data.get("status")),
"topic": slugify(clean_field(data.get("topic")) or "inne"),
"task_ids": task_ids,
"suggested_task_ids": [item for item in suggested_task_ids if item not in task_ids],
"rule_type": slugify(clean_field(data.get("rule_type")) or "recommendation"),
"condition": condition,
"recommendation": recommendation,
"risk": risk,
"source": clean_field(data.get("source")),
"source_file": clean_field(data.get("source_file")),
"confidence": slugify(clean_field(data.get("confidence")) or "medium"),
"created_at": created_at,
"updated_at": updated_at,
"duplicate_of": clean_field(data.get("duplicate_of")),
"supersedes": normalize_string_list(data.get("supersedes") or []),
"text": text,
}
validate_rule_row(row)
return row
def validate_rule_row(row: dict[str, Any]) -> None:
required = [
"id",
"status",
"topic",
"task_ids",
"suggested_task_ids",
"rule_type",
"condition",
"recommendation",
"risk",
"source",
"source_file",
"confidence",
"created_at",
"updated_at",
"duplicate_of",
"supersedes",
"text",
]
missing = [key for key in required if key not in row]
if missing:
raise ValueError(f"Regula nie ma wymaganych pol: {', '.join(missing)}")
if not isinstance(row["task_ids"], list):
raise ValueError("Pole task_ids musi byc lista.")
if not isinstance(row["suggested_task_ids"], list):
raise ValueError("Pole suggested_task_ids musi byc lista.")
if not isinstance(row["supersedes"], list):
raise ValueError("Pole supersedes musi byc lista.")
if not row["id"]:
raise ValueError("Regula musi miec id.")
if row["status"] not in ALLOWED_STATUSES:
raise ValueError(f"Nieprawidlowy status reguly: {row['status']}")
if not row["topic"]:
raise ValueError("Regula musi miec topic.")
if not row["source"]:
raise ValueError("Regula musi miec source.")
if not row["text"] and not row["recommendation"]:
raise ValueError("Regula musi miec text albo recommendation.")
def clean_field(value: Any) -> str:
return " ".join(str(value or "").replace("\r", "\n").split())
def now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def normalize_status(value: Any) -> str:
status = slugify(clean_field(value) or DEFAULT_STATUS)
if status not in ALLOWED_STATUSES:
return DEFAULT_STATUS
return status
def normalize_task_id_list(value: Any) -> list[str]:
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
return [slugify(str(item)) for item in value if str(item).strip()]
def normalize_string_list(value: Any) -> list[str]:
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
return [clean_field(item) for item in value if clean_field(item)]
def slugify(value: str, max_length: int = 96) -> str:
value = value.casefold()
replacements = {"\u0142": "l"}
for source, target in replacements.items():
value = value.replace(source, target)
value = unicodedata.normalize("NFKD", value)
value = "".join(char for char in value if not unicodedata.combining(char))
value = re.sub(r"[^a-z0-9]+", "_", value).strip("_")
value = re.sub(r"_+", "_", value)
return (value or "knowledge_rule")[:max_length].strip("_") or "knowledge_rule"
def unique_rule_id(base_id: str, existing_ids: set[str]) -> str:
candidate = base_id
suffix = 2
while candidate in existing_ids:
candidate = f"{base_id}_{suffix}"
suffix += 1
return candidate
def pending_task_suggestions(path: Path = RULES_PATH) -> list[dict[str, Any]]:
suggestions: list[dict[str, Any]] = []
for rule in load_rules(path):
if rule.status != "active":
continue
for task_id in rule.suggested_task_ids:
if task_id in rule.task_ids:
continue
suggestions.append({"rule": rule, "task_id": task_id})
return suggestions
def approve_task_suggestion(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
return update_task_suggestion(rule_id, task_id, approve=True, path=path)
def reject_task_suggestion(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
return update_task_suggestion(rule_id, task_id, approve=False, path=path)
def update_task_suggestion(
rule_id: str,
task_id: str,
approve: bool,
path: Path = RULES_PATH,
) -> KnowledgeRule:
normalized_rule_id = rule_id.strip()
normalized_task_id = slugify(task_id)
rows = read_rule_rows(path)
updated_rule: KnowledgeRule | None = None
for row in rows:
if str(row.get("id") or "").strip() != normalized_rule_id:
continue
task_ids = normalize_task_id_list(row.get("task_ids") or [])
suggested_task_ids = normalize_task_id_list(row.get("suggested_task_ids") or [])
if normalized_task_id not in suggested_task_ids:
raise ValueError(
f"Regula {normalized_rule_id} nie ma oczekujacej propozycji dla zadania {normalized_task_id}."
)
if approve and normalized_task_id not in task_ids:
task_ids.append(normalized_task_id)
row["task_ids"] = task_ids
row["suggested_task_ids"] = [item for item in suggested_task_ids if item != normalized_task_id]
row["updated_at"] = now_iso()
updated_rule = KnowledgeRule.from_dict(row)
break
if not updated_rule:
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
write_rule_rows(rows, path)
return updated_rule
def assign_rule_to_task(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
normalized_rule_id = rule_id.strip()
normalized_task_id = slugify(task_id)
rows = read_rule_rows(path)
updated_rule: KnowledgeRule | None = None
for row in rows:
if str(row.get("id") or "").strip() != normalized_rule_id:
continue
task_ids = normalize_task_id_list(row.get("task_ids") or [])
suggested_task_ids = normalize_task_id_list(row.get("suggested_task_ids") or [])
if normalized_task_id not in task_ids:
task_ids.append(normalized_task_id)
row["task_ids"] = task_ids
row["suggested_task_ids"] = [item for item in suggested_task_ids if item != normalized_task_id]
row["updated_at"] = now_iso()
updated_rule = KnowledgeRule.from_dict(row)
break
if not updated_rule:
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
write_rule_rows(rows, path)
return updated_rule
def delete_rule(rule_id: str, path: Path = RULES_PATH) -> KnowledgeRule:
normalized_rule_id = rule_id.strip()
rows = read_rule_rows(path)
remaining_rows: list[dict[str, Any]] = []
deleted_rule: KnowledgeRule | None = None
for row in rows:
if str(row.get("id") or "").strip() == normalized_rule_id and deleted_rule is None:
deleted_rule = KnowledgeRule.from_dict(row)
continue
remaining_rows.append(row)
if not deleted_rule:
raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}")
write_rule_rows(remaining_rows, path)
return deleted_rule
def unassigned_rules(path: Path = RULES_PATH) -> list[KnowledgeRule]:
return [
rule
for rule in load_rules(path)
if rule.status == "active" and not rule.task_ids
]
def load_review_state(path: Path = REVIEW_STATE_PATH) -> dict[str, Any]:
ensure_knowledge_store()
if not path.exists():
return {}
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def save_review_state(state: dict[str, Any], path: Path = REVIEW_STATE_PATH) -> None:
ensure_knowledge_store()
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
def reset_review_state(queue_name: str = "unassigned") -> None:
state = load_review_state()
state.pop(queue_name, None)
save_review_state(state)
def review_start_index(rules: list[KnowledgeRule], queue_name: str = "unassigned") -> int:
state = load_review_state()
queue_state = state.get(queue_name) or {}
last_rule_id = queue_state.get("last_rule_id", "")
if not last_rule_id:
return 0
for index, rule in enumerate(rules):
if rule.id == last_rule_id:
return min(index + 1, len(rules))
last_queue_index = queue_state.get("last_queue_index")
if isinstance(last_queue_index, int):
return min(max(last_queue_index, 0), len(rules))
last_sort_key = queue_state.get("last_sort_key")
if isinstance(last_sort_key, list) and len(last_sort_key) == 2:
normalized_last_sort_key = (str(last_sort_key[0]), str(last_sort_key[1]))
for index, rule in enumerate(rules):
if (rule.topic, rule.id) > normalized_last_sort_key:
return index
return len(rules)
return 0
def mark_review_progress(
rule_id: str,
queue_name: str = "unassigned",
sort_key: list[str] | None = None,
queue_index: int | None = None,
) -> None:
state = load_review_state()
queue_state = {
"last_rule_id": rule_id,
"updated_at": now_iso(),
}
if sort_key:
queue_state["last_sort_key"] = [str(sort_key[0]), str(sort_key[1])]
if queue_index is not None:
queue_state["last_queue_index"] = queue_index
state[queue_name] = queue_state
save_review_state(state)
def rule_preview(rule: KnowledgeRule, max_length: int = 90) -> str:
value = rule.recommendation or rule.text or rule.condition or rule.risk
value = " ".join(value.split())
if len(value) <= max_length:
return value
return value[: max_length - 3].rstrip() + "..."

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import lancedb
import requests
from .store import LANCEDB_DIR, KnowledgeRule, ensure_knowledge_store, load_rules, now_iso
TABLE_NAME = "rules"
INDEX_META_PATH = LANCEDB_DIR / "index_meta.json"
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
EMBEDDING_BATCH_SIZE = 64
@dataclass(frozen=True)
class IndexBuildResult:
table_path: Path
model: str
rules_count: int
@dataclass(frozen=True)
class SemanticSearchResult:
id: str
topic: str
task_ids: str
source: str
text: str
distance: float
def build_vector_index(model: str | None = None) -> IndexBuildResult:
ensure_knowledge_store()
storage_dir = lancedb_storage_dir()
storage_dir.mkdir(parents=True, exist_ok=True)
selected_model = embedding_model(model)
rules = [rule for rule in load_rules() if rule.status == "active"]
if not rules:
raise RuntimeError("Brak aktywnych regul do zaindeksowania.")
texts = [rule_search_text(rule) for rule in rules]
vectors = embed_texts(texts, selected_model)
rows = [rule_to_lancedb_row(rule, text, vector) for rule, text, vector in zip(rules, texts, vectors)]
db = lancedb.connect(str(storage_dir))
db.create_table(TABLE_NAME, data=rows, mode="overwrite")
meta = {
"created_at": now_iso(),
"model": selected_model,
"rules_count": len(rows),
"table": TABLE_NAME,
"storage_dir": str(storage_dir),
"table_path": str(storage_dir / f"{TABLE_NAME}.lance"),
"rule_ids": [rule.id for rule in rules],
}
INDEX_META_PATH.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return IndexBuildResult(
table_path=storage_dir / f"{TABLE_NAME}.lance",
model=selected_model,
rules_count=len(rows),
)
def semantic_search(query: str, limit: int = 10, model: str | None = None) -> list[SemanticSearchResult]:
ensure_knowledge_store()
query = query.strip()
if not query:
return []
selected_model = embedding_model(model)
storage_dir = lancedb_storage_dir()
db = lancedb.connect(str(storage_dir))
if TABLE_NAME not in table_names(db):
raise RuntimeError("Brak indeksu LanceDB. Uruchom: python gads.py wiedza indeksuj")
query_vector = embed_texts([query], selected_model)[0]
table = db.open_table(TABLE_NAME)
rows = table.search(query_vector).limit(max(limit, 0)).to_list()
return [
SemanticSearchResult(
id=str(row.get("id") or ""),
topic=str(row.get("topic") or ""),
task_ids=str(row.get("task_ids") or ""),
source=str(row.get("source") or ""),
text=str(row.get("text") or ""),
distance=float(row.get("_distance") or 0.0),
)
for row in rows
]
def vector_index_summary() -> dict[str, Any]:
ensure_knowledge_store()
if not INDEX_META_PATH.exists():
return {}
meta = json.loads(INDEX_META_PATH.read_text(encoding="utf-8"))
table_path = Path(str(meta.get("table_path") or ""))
current_table_path = lancedb_storage_dir() / f"{TABLE_NAME}.lance"
meta["available"] = table_path.exists()
meta["current_storage_dir"] = str(lancedb_storage_dir())
meta["current_table_path"] = str(current_table_path)
meta["current_available"] = current_table_path.exists()
return meta
def lancedb_storage_dir() -> Path:
configured = os.environ.get("KNOWLEDGE_LANCEDB_DIR")
if configured:
return Path(configured)
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
return Path(local_app_data) / "google-ads-ver2-knowledge-lancedb"
return LANCEDB_DIR
def embedding_model(model: str | None = None) -> str:
return (
model
or os.environ.get("KNOWLEDGE_EMBEDDING_MODEL")
or DEFAULT_EMBEDDING_MODEL
)
def embed_texts(texts: list[str], model: str) -> list[list[float]]:
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("Brak OPENAI_API_KEY. Dodaj klucz do .env.")
vectors: list[list[float]] = []
for start in range(0, len(texts), EMBEDDING_BATCH_SIZE):
batch = texts[start : start + EMBEDDING_BATCH_SIZE]
vectors.extend(call_embeddings_api(api_key, model, batch))
if len(vectors) != len(texts):
raise RuntimeError("Liczba embeddingow nie zgadza sie z liczba tekstow.")
return vectors
def call_embeddings_api(api_key: str, model: str, texts: list[str]) -> list[list[float]]:
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
response = requests.post(
f"{base_url}/embeddings",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"input": texts,
"encoding_format": "float",
},
timeout=120,
)
if response.status_code >= 400:
raise RuntimeError(f"OpenAI embeddings API zwrocilo blad {response.status_code}: {response.text}")
payload = response.json()
data = payload.get("data") or []
data = sorted(data, key=lambda item: int(item.get("index", 0)))
vectors = [item.get("embedding") for item in data]
if not all(isinstance(vector, list) and vector for vector in vectors):
raise RuntimeError("OpenAI embeddings API nie zwrocilo poprawnych wektorow.")
return vectors
def rule_to_lancedb_row(rule: KnowledgeRule, search_text: str, vector: list[float]) -> dict[str, Any]:
return {
"id": rule.id,
"status": rule.status,
"topic": rule.topic,
"task_ids": ", ".join(rule.task_ids),
"suggested_task_ids": ", ".join(rule.suggested_task_ids),
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
"source_file": rule.source_file,
"confidence": rule.confidence,
"created_at": rule.created_at,
"updated_at": rule.updated_at,
"duplicate_of": rule.duplicate_of,
"supersedes": ", ".join(rule.supersedes),
"text": rule.text,
"search_text": search_text,
"vector": vector,
}
def rule_search_text(rule: KnowledgeRule) -> str:
values = [
f"ID: {rule.id}",
f"Temat: {rule.topic}",
f"Typ: {rule.rule_type}",
f"Warunek: {rule.condition}",
f"Rekomendacja: {rule.recommendation}",
f"Ryzyko: {rule.risk}",
f"Tekst: {rule.text}",
f"Zadania: {', '.join(rule.task_ids)}",
f"Zrodlo: {rule.source}",
]
return "\n".join(value for value in values if value.strip())
def table_names(db: Any) -> list[str]:
if hasattr(db, "table_names"):
return list(db.table_names())
return list(db.list_tables())

197
src/gads_v2/reminders.py Normal file
View File

@@ -0,0 +1,197 @@
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from .config import ROOT, client_dir
from .history import now_local
from .table import print_table
GLOBAL_REMINDERS_PATH = ROOT / "reminders.jsonl"
@dataclass(frozen=True)
class Reminder:
id: str
created_at: str
due_date: str
text: str
client: str
status: str
@classmethod
def from_dict(cls, data: dict) -> "Reminder":
return cls(
id=str(data.get("id", "")),
created_at=str(data.get("created_at", "")),
due_date=str(data.get("due_date", "")),
text=str(data.get("text", "")),
client=str(data.get("client", "")),
status=str(data.get("status", "active")),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"created_at": self.created_at,
"due_date": self.due_date,
"text": self.text,
"client": self.client,
"status": self.status,
}
def normalize_text(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch))
return without_marks.lower()
def reminder_path(domain: str | None = None) -> Path:
if domain:
base = client_dir(domain)
return base / "reminders.jsonl"
return GLOBAL_REMINDERS_PATH
def load_reminders(domain: str | None = None, include_global: bool = False) -> list[Reminder]:
paths = []
if include_global:
paths.append(GLOBAL_REMINDERS_PATH)
paths.append(reminder_path(domain))
reminders: list[Reminder] = []
for path in paths:
if not path.exists():
continue
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
reminders.append(Reminder.from_dict(json.loads(line)))
except json.JSONDecodeError:
continue
reminders.sort(key=lambda item: (item.due_date, item.created_at, item.id))
return reminders
def parse_reminder_text(raw_text: str) -> tuple[date, str]:
text = " ".join(raw_text.split()).strip()
if not text:
raise ValueError("Brak tresci przypomnienia.")
today = now_local().date()
normalized = normalize_text(text)
due = today
matched_prefix = ""
patterns = [
(r"^za\s+(\d+)\s+dni(?:\s+|$)", "days"),
(r"^za\s+(\d+)\s+dzien(?:\s+|$)", "days"),
(r"^za\s+(\d+)\s+tygodnie(?:\s+|$)", "weeks"),
(r"^za\s+(\d+)\s+tygodni(?:\s+|$)", "weeks"),
(r"^za\s+(\d+)\s+tydzien(?:\s+|$)", "weeks"),
(r"^za\s+(\d+)\s+miesiace(?:\s+|$)", "months"),
(r"^za\s+(\d+)\s+miesiecy(?:\s+|$)", "months"),
(r"^za\s+(\d+)\s+miesiac(?:\s+|$)", "months"),
]
for pattern, unit in patterns:
match = re.search(pattern, normalized)
if not match:
continue
amount = int(match.group(1))
if unit == "days":
due = today + timedelta(days=amount)
elif unit == "weeks":
due = today + timedelta(weeks=amount)
else:
due = today + timedelta(days=amount * 30)
matched_prefix = text[: match.end()].strip()
break
if not matched_prefix:
if normalized.startswith("jutro"):
due = today + timedelta(days=1)
matched_prefix = text[:5].strip()
elif normalized.startswith("dzisiaj"):
due = today
matched_prefix = text[:7].strip()
note = text[len(matched_prefix) :].strip() if matched_prefix else text
note_normalized = normalize_text(note)
for prefix in [
"przypomnij mi o ",
"przypomnij o ",
"przypomnienie o ",
"o ",
]:
if note_normalized.startswith(prefix):
note = note[len(prefix) :].strip()
break
if not note:
note = text
return due, note
def add_reminder(raw_text: str, domain: str | None = None) -> Reminder:
due, note = parse_reminder_text(raw_text)
ts = now_local()
reminder = Reminder(
id=f"rem_{ts.strftime('%Y%m%d%H%M%S')}",
created_at=ts.isoformat(timespec="seconds"),
due_date=due.isoformat(),
text=note,
client=domain or "",
status="active",
)
path = reminder_path(domain)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(reminder.to_dict(), ensure_ascii=False) + "\n")
return reminder
def active_reminders_for_client(domain: str) -> list[Reminder]:
return [item for item in load_reminders(domain, include_global=True) if item.status == "active"]
def reminder_status_label(reminder: Reminder) -> str:
today = now_local().date()
try:
due = date.fromisoformat(reminder.due_date)
except ValueError:
return "brak daty"
days = (due - today).days
if days < 0:
return f"po terminie {abs(days)} dni"
if days == 0:
return "dzisiaj"
if days == 1:
return "jutro"
return f"za {days} dni"
def print_client_reminders(domain: str, limit: int = 12) -> None:
reminders = active_reminders_for_client(domain)
if not reminders:
return
print("\nPrzypomnienia")
shown = reminders[:limit]
print_table(
["Termin", "Status", "Zakres", "Notatka"],
[
[
reminder.due_date,
reminder_status_label(reminder),
reminder.client or "globalne",
reminder.text,
]
for reminder in shown
],
)
if len(reminders) > len(shown):
print(f"... oraz {len(reminders) - len(shown)} kolejnych przypomnien")

25
src/gads_v2/table.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
def print_table(headers: list[str], rows: list[list[str]]) -> None:
widths = [len(header) for header in headers]
for row in rows:
for index, value in enumerate(row):
widths[index] = max(widths[index], len(str(value)))
def border(left: str, middle: str, right: str) -> str:
return left + middle.join("" * (width + 2) for width in widths) + right
def line(values: list[str]) -> str:
cells = [f" {str(value):<{widths[index]}} " for index, value in enumerate(values)]
return "" + "".join(cells) + ""
print(border("", "", ""))
print(line(headers))
print(border("", "", ""))
for index, row in enumerate(rows):
print(line([str(value) for value in row]))
if index != len(rows) - 1:
print(border("", "", ""))
print(border("", "", ""))

126
src/gads_v2/task_catalog.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from .config import ROOT
from .table import print_table
@dataclass(frozen=True)
class Task:
id: str
name: str
description: str
group_id: str
group_name: str
number: int
group_number: int
index_in_group: int
@property
def selection(self) -> str:
return f"{self.group_number}.{self.index_in_group}"
@dataclass(frozen=True)
class TaskGroup:
id: str
name: str
number: int
def load_task_config() -> dict:
path = ROOT / "config" / "tasks.toml"
return tomllib.loads(path.read_text(encoding="utf-8"))
def load_groups() -> list[TaskGroup]:
data = load_task_config()
groups = []
for index, group in enumerate(data.get("groups", []), 1):
groups.append(TaskGroup(id=group["id"], name=group["name"], number=index))
return groups
def load_tasks() -> list[Task]:
data = load_task_config()
tasks: list[Task] = []
number = 1
for group_number, group in enumerate(data.get("groups", []), 1):
for index_in_group, row in enumerate(group.get("tasks", []), 1):
tasks.append(
Task(
id=row["id"],
name=row["name"],
description=row.get("description", ""),
group_id=group["id"],
group_name=group["name"],
number=number,
group_number=group_number,
index_in_group=index_in_group,
)
)
number += 1
return tasks
def task_by_number(tasks: list[Task], number: int) -> Task | None:
for task in tasks:
if task.number == number:
return task
return None
def task_by_selection(tasks: list[Task], selection: str) -> Task | None:
normalized = selection.strip().lower()
for task in tasks:
if task.selection.lower() == normalized:
return task
return None
def tasks_by_selection_group(tasks: list[Task], groups: list[TaskGroup], selection: str) -> list[Task]:
normalized = selection.strip().lower()
if not normalized.endswith(".0"):
return []
try:
group_number = int(normalized.split(".", 1)[0])
except ValueError:
return []
return tasks_by_group_number(tasks, groups, group_number)
def tasks_by_group_number(tasks: list[Task], groups: list[TaskGroup], number: int) -> list[Task]:
group = next((item for item in groups if item.number == number), None)
if not group:
return []
return [task for task in tasks if task.group_id == group.id]
def print_task_list(tasks: list[Task]) -> None:
groups = load_groups()
for group in groups:
group_tasks = [task for task in tasks if task.group_id == group.id]
if not group_tasks:
continue
print()
print("=" * 72)
print(f"GRUPA {group.number}: {group.name.upper()}")
print("=" * 72)
print_table(
["Nr", "Zadanie", "Opis"],
[[item.selection, item.name, item.description] for item in group_tasks],
)
print()
print("Opcje zbiorcze")
group_rows = [
[f"{group.number}.0", f"Wszystkie zadania z grupy: {group.name}"]
for group in groups
if any(task.group_id == group.id for task in tasks)
]
print_table(
["Nr", "Zakres"],
group_rows + [["ALL", "Wszystkie zadania ze wszystkich grup"]],
)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,949 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_account_anomalies"
TASK_NAME = "Sprawdzenie anomalii konta"
SCOPE = [
{
"area": "Okres porownania",
"check": "Porownaj ostatnie 7 zakonczonych dni z poprzednimi 7 dniami, bez uzywania niepelnych danych z dzisiaj.",
},
{
"area": "Metryki kampanii",
"check": "Sprawdz koszt, klikniecia, wyswietlenia, konwersje, wartosc konwersji, CTR, CPC i ROAS na poziomie aktywnych kampanii.",
},
{
"area": "Nagly spadek",
"check": "Oznacz kampanie, w ktorych spadl ruch, koszt, konwersje, wartosc konwersji albo ROAS.",
},
{
"area": "Nagly wzrost",
"check": "Oznacz kampanie, w ktorych koszt, CPC albo ruch wzrosly szybciej niz wyniki.",
},
{
"area": "Priorytet reakcji",
"check": "Nadaj anomaliom poziom waznosci, aby agent mogl szybko zdecydowac, ktore kampanie sprawdzic jako pierwsze.",
},
]
OUT_OF_SCOPE = [
"zmiany budzetow i ocena pacingu budzetu",
"zmiany strategii stawek oraz celow Docelowy ROAS/Docelowy CPA",
"analiza zapytan uzytkownikow oraz wykluczen",
"analiza reklam RSA, zasobow i kreacji",
"wdrazanie zmian na koncie Google Ads",
]
@dataclass
class AccountAnomalyPlan:
currency_code: str
recent_period: dict
previous_period: dict
account_summary: list[dict]
campaigns: list[dict]
anomalies: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"recent_period": self.recent_period,
"previous_period": self.previous_period,
"account_summary": self.account_summary,
"campaigns": self.campaigns,
"anomalies": self.anomalies,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "AccountAnomalyPlan":
return cls(
currency_code=data.get("currency_code", ""),
recent_period=data.get("recent_period", {}),
previous_period=data.get("previous_period", {}),
account_summary=data.get("account_summary", []),
campaigns=data.get("campaigns", []),
anomalies=data.get("anomalies", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money_micros(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def format_money_amount(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{float(value or 0):.2f}{suffix}"
def format_number(value: int | float, decimals: int = 0) -> str:
if decimals <= 0:
return str(int(round(float(value or 0))))
return f"{float(value or 0):.{decimals}f}"
def format_percent_value(value: int | float) -> str:
return f"{float(value or 0):.1f}%"
def format_change(value: float | None) -> str:
if value is None:
return "nowe dane"
return f"{value:+.1f}%"
def pct_change(previous: float, recent: float) -> float | None:
if previous == 0:
return None
return round(((recent - previous) / previous) * 100, 1)
def percent(numerator: int | float, denominator: int | float) -> float:
if not denominator:
return 0.0
return round((float(numerator) / float(denominator)) * 100, 2)
def empty_metrics() -> dict:
return {
"cost_micros": 0,
"clicks": 0,
"impressions": 0,
"conversions": 0.0,
"conversion_value": 0.0,
}
def add_metrics(target: dict, metrics: Any) -> None:
target["cost_micros"] += int(metrics.cost_micros or 0)
target["clicks"] += int(metrics.clicks or 0)
target["impressions"] += int(metrics.impressions or 0)
target["conversions"] += float(metrics.conversions or 0)
target["conversion_value"] += float(metrics.conversions_value or 0)
def derived_metrics(metrics: dict) -> dict:
cost_amount = micros_to_amount(metrics["cost_micros"])
clicks = metrics["clicks"]
impressions = metrics["impressions"]
conversions = metrics["conversions"]
conversion_value = metrics["conversion_value"]
return {
**metrics,
"cost_amount": cost_amount,
"ctr_percent": percent(clicks, impressions),
"avg_cpc_micros": int(metrics["cost_micros"] / clicks) if clicks else 0,
"conversion_rate_percent": percent(conversions, clicks),
"roas": round(conversion_value / cost_amount, 2) if cost_amount else 0.0,
}
def period_labels() -> tuple[dict, dict]:
today = now_local().date()
recent_end = today - timedelta(days=1)
recent_start = recent_end - timedelta(days=6)
previous_end = recent_start - timedelta(days=1)
previous_start = previous_end - timedelta(days=6)
return (
{
"label": "ostatnie 7 zakonczonych dni",
"start": recent_start.isoformat(),
"end": recent_end.isoformat(),
},
{
"label": "poprzednie 7 dni",
"start": previous_start.isoformat(),
"end": previous_end.isoformat(),
},
)
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def fetch_campaign_period_metrics(client_config: ClientConfig) -> tuple[str, dict, dict, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
recent_period, previous_period = period_labels()
rows = run_query(
google_client,
customer_id,
f"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
segments.date,
metrics.cost_micros,
metrics.clicks,
metrics.impressions,
metrics.conversions,
metrics.conversions_value
FROM campaign
WHERE campaign.status = 'ENABLED'
AND segments.date BETWEEN '{previous_period["start"]}' AND '{recent_period["end"]}'
""",
)
campaigns: dict[str, dict] = {}
for row in rows:
campaign = row.campaign
campaign_id = str(campaign.id)
record = campaigns.setdefault(
campaign_id,
{
"campaign_id": campaign_id,
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"recent": empty_metrics(),
"previous": empty_metrics(),
},
)
row_date = str(row.segments.date)
if recent_period["start"] <= row_date <= recent_period["end"]:
add_metrics(record["recent"], row.metrics)
elif previous_period["start"] <= row_date <= previous_period["end"]:
add_metrics(record["previous"], row.metrics)
campaign_rows = []
for record in campaigns.values():
recent = derived_metrics(record["recent"])
previous = derived_metrics(record["previous"])
campaign_rows.append(
{
"campaign_id": record["campaign_id"],
"campaign_name": record["campaign_name"],
"status": record["status"],
"channel_type": record["channel_type"],
"recent": recent,
"previous": previous,
"changes": metric_changes(previous, recent),
}
)
campaign_rows.sort(key=lambda row: (-row["recent"]["cost_micros"], row["campaign_name"]))
return currency_code, recent_period, previous_period, campaign_rows
def metric_changes(previous: dict, recent: dict) -> dict:
return {
"cost": pct_change(previous["cost_micros"], recent["cost_micros"]),
"clicks": pct_change(previous["clicks"], recent["clicks"]),
"impressions": pct_change(previous["impressions"], recent["impressions"]),
"conversions": pct_change(previous["conversions"], recent["conversions"]),
"conversion_value": pct_change(previous["conversion_value"], recent["conversion_value"]),
"ctr": pct_change(previous["ctr_percent"], recent["ctr_percent"]),
"avg_cpc": pct_change(previous["avg_cpc_micros"], recent["avg_cpc_micros"]),
"roas": pct_change(previous["roas"], recent["roas"]),
}
def aggregate_account_summary(campaigns: list[dict], currency_code: str) -> list[dict]:
recent = empty_metrics()
previous = empty_metrics()
for campaign in campaigns:
for key in recent:
recent[key] += campaign["recent"][key]
previous[key] += campaign["previous"][key]
recent = derived_metrics(recent)
previous = derived_metrics(previous)
return [
summary_row("Koszt", previous["cost_micros"], recent["cost_micros"], "money_micros", currency_code),
summary_row("Klikniecia", previous["clicks"], recent["clicks"], "number", currency_code),
summary_row("Wyswietlenia", previous["impressions"], recent["impressions"], "number", currency_code),
summary_row("Konwersje", previous["conversions"], recent["conversions"], "decimal", currency_code),
summary_row(
"Wartosc konwersji",
previous["conversion_value"],
recent["conversion_value"],
"money_amount",
currency_code,
),
summary_row("CTR", previous["ctr_percent"], recent["ctr_percent"], "percent", currency_code),
summary_row("Sredni CPC", previous["avg_cpc_micros"], recent["avg_cpc_micros"], "money_micros", currency_code),
summary_row("ROAS", previous["roas"], recent["roas"], "decimal", currency_code),
]
def summary_row(label: str, previous: float, recent: float, value_type: str, currency_code: str) -> dict:
return {
"metric": label,
"previous": display_metric(previous, value_type, currency_code),
"recent": display_metric(recent, value_type, currency_code),
"change_percent": format_change(pct_change(previous, recent)),
}
def display_metric(value: float, value_type: str, currency_code: str) -> str:
if value_type == "money_micros":
return format_money_micros(value, currency_code)
if value_type == "money_amount":
return format_money_amount(value, currency_code)
if value_type == "percent":
return format_percent_value(value)
if value_type == "decimal":
return format_number(value, 2)
return format_number(value)
def anomaly(
campaign: dict,
metric: str,
severity: str,
previous_value: str,
recent_value: str,
change_percent: str,
reason: str,
recommendation: str,
) -> dict:
return {
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"channel_type": campaign["channel_type"],
"status": campaign["status"],
"metric": metric,
"severity": severity,
"previous_value": previous_value,
"recent_value": recent_value,
"change_percent": change_percent,
"reason": reason,
"recommendation": recommendation,
}
def build_campaign_anomalies(campaign: dict, currency_code: str) -> list[dict]:
recent = campaign["recent"]
previous = campaign["previous"]
changes = campaign["changes"]
found = []
if previous["cost_micros"] >= 50_000_000 and recent["cost_micros"] == 0:
found.append(
anomaly(
campaign,
"koszt",
"wysokie",
format_money_micros(previous["cost_micros"], currency_code),
format_money_micros(recent["cost_micros"], currency_code),
"-100.0%",
"kampania miala koszt w poprzednim okresie i nie ma kosztu w ostatnich 7 dniach",
"sprawdz status kampanii, budzet, odrzucenia, pomiar i ostatnie zmiany",
)
)
elif previous["cost_micros"] == 0 and recent["cost_micros"] >= 50_000_000:
found.append(
anomaly(
campaign,
"koszt",
"niskie",
format_money_micros(previous["cost_micros"], currency_code),
format_money_micros(recent["cost_micros"], currency_code),
"nowe dane",
"kampania zaczela wydawac srodki po okresie bez kosztu",
"sprawdz, czy start kampanii byl planowany i czy wyniki sa akceptowalne",
)
)
if changes["cost"] is not None and previous["cost_micros"] >= 50_000_000:
if changes["cost"] >= 60:
found.append(
anomaly(
campaign,
"koszt",
"srednie",
format_money_micros(previous["cost_micros"], currency_code),
format_money_micros(recent["cost_micros"], currency_code),
format_change(changes["cost"]),
"koszt wzrosl szybciej niz typowy tygodniowy prog alarmowy",
"sprawdz budzet, strategie stawek i zmiany ruchu w osobnych zadaniach",
)
)
elif changes["cost"] <= -50:
found.append(
anomaly(
campaign,
"koszt",
"srednie",
format_money_micros(previous["cost_micros"], currency_code),
format_money_micros(recent["cost_micros"], currency_code),
format_change(changes["cost"]),
"koszt spadl o co najmniej polowe tydzien do tygodnia",
"sprawdz, czy spadek wynika z decyzji, limitow, odrzucen albo utraty ruchu",
)
)
if changes["clicks"] is not None and previous["clicks"] >= 20:
if changes["clicks"] <= -45:
found.append(
anomaly(
campaign,
"klikniecia",
"srednie",
format_number(previous["clicks"]),
format_number(recent["clicks"]),
format_change(changes["clicks"]),
"klikniecia spadly mocno przy sensownym wolumenie bazowym",
"sprawdz aukcje, status kampanii, budzet i zapytania w zadaniach szczegolowych",
)
)
elif changes["clicks"] >= 80:
found.append(
anomaly(
campaign,
"klikniecia",
"niskie",
format_number(previous["clicks"]),
format_number(recent["clicks"]),
format_change(changes["clicks"]),
"klikniecia wzrosly bardzo mocno tydzien do tygodnia",
"sprawdz, czy wzrost jest jakosciowy i nie wynika z niepasujacego ruchu",
)
)
if changes["conversions"] is not None and previous["conversions"] >= 3:
if recent["conversions"] == 0:
severity = "wysokie"
else:
severity = "srednie"
if changes["conversions"] <= -50:
found.append(
anomaly(
campaign,
"konwersje",
severity,
format_number(previous["conversions"], 2),
format_number(recent["conversions"], 2),
format_change(changes["conversions"]),
"konwersje spadly o co najmniej polowe",
"sprawdz pomiar konwersji, ruch i ostatnie zmiany w kampanii",
)
)
elif changes["conversions"] >= 80:
found.append(
anomaly(
campaign,
"konwersje",
"niskie",
format_number(previous["conversions"], 2),
format_number(recent["conversions"], 2),
format_change(changes["conversions"]),
"konwersje wzrosly bardzo mocno tydzien do tygodnia",
"sprawdz, czy wzrost jest rzeczywisty i czy nie zmienil sie pomiar",
)
)
if changes["conversion_value"] is not None and previous["conversion_value"] >= 100:
if changes["conversion_value"] <= -50:
found.append(
anomaly(
campaign,
"wartosc konwersji",
"wysokie",
format_money_amount(previous["conversion_value"], currency_code),
format_money_amount(recent["conversion_value"], currency_code),
format_change(changes["conversion_value"]),
"wartosc konwersji spadla o co najmniej polowe",
"sprawdz pomiar, koszyk, kampanie produktowe i jakosc ruchu",
)
)
elif changes["conversion_value"] >= 100:
found.append(
anomaly(
campaign,
"wartosc konwersji",
"niskie",
format_money_amount(previous["conversion_value"], currency_code),
format_money_amount(recent["conversion_value"], currency_code),
format_change(changes["conversion_value"]),
"wartosc konwersji wzrosla ponad dwukrotnie",
"sprawdz, czy wzrost wynika z realnej sprzedazy, a nie zmiany pomiaru",
)
)
if changes["ctr"] is not None and previous["impressions"] >= 500 and previous["ctr_percent"] >= 1:
if changes["ctr"] <= -35:
found.append(
anomaly(
campaign,
"CTR",
"srednie",
format_percent_value(previous["ctr_percent"]),
format_percent_value(recent["ctr_percent"]),
format_change(changes["ctr"]),
"CTR spadl przy wystarczajacej liczbie wyswietlen",
"sprawdz dopasowanie ruchu, reklamy i zmiany konkurencji",
)
)
if changes["avg_cpc"] is not None and previous["clicks"] >= 20:
if changes["avg_cpc"] >= 60:
found.append(
anomaly(
campaign,
"sredni CPC",
"srednie",
format_money_micros(previous["avg_cpc_micros"], currency_code),
format_money_micros(recent["avg_cpc_micros"], currency_code),
format_change(changes["avg_cpc"]),
"sredni CPC wzrosl mocno tydzien do tygodnia",
"sprawdz strategie stawek, aukcje i segmenty ruchu",
)
)
if changes["roas"] is not None and previous["cost_micros"] >= 50_000_000 and previous["roas"] >= 1:
if changes["roas"] <= -40:
found.append(
anomaly(
campaign,
"ROAS",
"wysokie",
format_number(previous["roas"], 2),
format_number(recent["roas"], 2),
format_change(changes["roas"]),
"ROAS spadl przy istotnym koszcie bazowym",
"sprawdz rentownosc, pomiar, produkt/feed i strategie stawek w zadaniach szczegolowych",
)
)
return found
def build_anomalies(campaigns: list[dict], currency_code: str) -> list[dict]:
anomalies = []
for campaign in campaigns:
anomalies.extend(build_campaign_anomalies(campaign, currency_code))
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2}
anomalies.sort(
key=lambda row: (
severity_order.get(row["severity"], 9),
row["campaign_name"],
row["metric"],
)
)
return anomalies
def compact_campaign_row(campaign: dict, currency_code: str) -> dict:
previous = campaign["previous"]
recent = campaign["recent"]
return {
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"status": campaign["status"],
"channel_type": campaign["channel_type"],
"previous_cost": format_money_micros(previous["cost_micros"], currency_code),
"recent_cost": format_money_micros(recent["cost_micros"], currency_code),
"cost_change": format_change(campaign["changes"]["cost"]),
"previous_clicks": format_number(previous["clicks"]),
"recent_clicks": format_number(recent["clicks"]),
"clicks_change": format_change(campaign["changes"]["clicks"]),
"previous_conversions": format_number(previous["conversions"], 2),
"recent_conversions": format_number(recent["conversions"], 2),
"conversions_change": format_change(campaign["changes"]["conversions"]),
"previous_roas": format_number(previous["roas"], 2),
"recent_roas": format_number(recent["roas"], 2),
"roas_change": format_change(campaign["changes"]["roas"]),
}
def build_account_anomaly_plan(client_config: ClientConfig) -> AccountAnomalyPlan:
warnings = []
recent_period, previous_period = period_labels()
try:
currency_code, recent_period, previous_period, campaigns_raw = fetch_campaign_period_metrics(client_config)
except Exception as exc:
currency_code = ""
campaigns_raw = []
warnings.append(f"Nie udalo sie pobrac metryk kampanii z Google Ads API: {exc}")
if not campaigns_raw:
warnings.append("Nie znaleziono kampanii z danymi w analizowanym okresie albo nie udalo sie ich pobrac.")
anomalies = build_anomalies(campaigns_raw, currency_code)
if not anomalies and campaigns_raw:
warnings.append("Nie wykryto anomalii wedlug obecnych progow. Wyniki nadal warto porownac z kontekstem klienta.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace anomalii i alertow bedziemy dopisywac osobno po akceptacji uzytkownika."
)
campaigns = [compact_campaign_row(campaign, currency_code) for campaign in campaigns_raw]
return AccountAnomalyPlan(
currency_code=currency_code,
recent_period=recent_period,
previous_period=previous_period,
account_summary=aggregate_account_summary(campaigns_raw, currency_code),
campaigns=campaigns,
anomalies=anomalies,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_account_anomaly_plan(domain: str, plan: AccountAnomalyPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie anomalii konta",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Okresy",
"",
f"- Ostatnie 7 zakonczonych dni: {plan.recent_period.get('start', '')} - {plan.recent_period.get('end', '')}",
f"- Poprzednie 7 dni: {plan.previous_period.get('start', '')} - {plan.previous_period.get('end', '')}",
"",
"## Podsumowanie",
"",
f"- Kampanie z danymi: {len(plan.campaigns)}",
f"- Wykryte anomalie: {len(plan.anomalies)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.account_summary:
lines.extend(
[
"## Podsumowanie konta",
"",
"| Metryka | Poprzednie 7 dni | Ostatnie 7 dni | Zmiana |",
"| --- | --- | --- | --- |",
]
)
for row in plan.account_summary:
lines.append(f"| {row['metric']} | {row['previous']} | {row['recent']} | {row['change_percent']} |")
lines.append("")
if plan.anomalies:
lines.extend(
[
"## Wykryte anomalie",
"",
"| Waznosc | Kampania | Metryka | Poprzednio | Teraz | Zmiana | Powod | Rekomendacja |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.anomalies:
lines.append(
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['metric']} | "
f"{item['previous_value']} | {item['recent_value']} | {item['change_percent']} | "
f"{md_cell(item['reason'])} | {md_cell(item['recommendation'])} |"
)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Porownanie kampanii",
"",
"| Kampania | Typ | Status | Koszt poprzednio | Koszt teraz | Zmiana kosztu | Konwersje poprzednio | Konwersje teraz | Zmiana konwersji | ROAS poprzednio | ROAS teraz | Zmiana ROAS |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
f"{campaign['previous_cost']} | {campaign['recent_cost']} | {campaign['cost_change']} | "
f"{campaign['previous_conversions']} | {campaign['recent_conversions']} | {campaign['conversions_change']} | "
f"{campaign['previous_roas']} | {campaign['recent_roas']} | {campaign['roas_change']} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_account_anomaly_plan(plan: AccountAnomalyPlan) -> None:
print("\nPlan sprawdzenia anomalii konta")
print_table(
["Metryka", "Liczba"],
[
["Kampanie z danymi", str(len(plan.campaigns))],
["Wykryte anomalie", str(len(plan.anomalies))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
print("\nOkresy porownania")
print_table(
["Okres", "Od", "Do"],
[
[plan.previous_period.get("label", "poprzednie 7 dni"), plan.previous_period.get("start", ""), plan.previous_period.get("end", "")],
[plan.recent_period.get("label", "ostatnie 7 zakonczonych dni"), plan.recent_period.get("start", ""), plan.recent_period.get("end", "")],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.account_summary:
print("\nPodsumowanie konta")
print_table(
["Metryka", "Poprzednie 7 dni", "Ostatnie 7 dni", "Zmiana"],
[[row["metric"], row["previous"], row["recent"], row["change_percent"]] for row in plan.account_summary],
)
if plan.anomalies:
print("\nWykryte anomalie")
print_table(
["Nr", "Waznosc", "Kampania", "Metryka", "Poprzednio", "Teraz", "Zmiana", "Powod"],
[
[
str(index),
item["severity"],
item["campaign_name"],
item["metric"],
item["previous_value"],
item["recent_value"],
item["change_percent"],
item["reason"],
]
for index, item in enumerate(plan.anomalies[:30], 1)
],
)
if len(plan.anomalies) > 30:
print(f"... oraz {len(plan.anomalies) - 30} kolejnych anomalii w pliku planu")
if plan.campaigns:
print("\nPorownanie kampanii")
print_table(
["Nr", "Kampania", "Typ", "Koszt poprz.", "Koszt teraz", "Zmiana", "Konw. poprz.", "Konw. teraz", "ROAS teraz"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
campaign["previous_cost"],
campaign["recent_cost"],
campaign["cost_change"],
campaign["previous_conversions"],
campaign["recent_conversions"],
campaign["recent_roas"],
]
for index, campaign in enumerate(plan.campaigns[:30], 1)
],
)
if len(plan.campaigns) > 30:
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_account_anomaly_plan(
client_config: ClientConfig,
plan: AccountAnomalyPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem anomalii i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(item["campaign_name"] for item in plan.anomalies[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"anomalies": len(plan.anomalies),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_account_anomalies(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = AccountAnomalyPlan.from_dict(plan_data)
print_account_anomaly_plan(plan)
apply_account_anomaly_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia anomalii konta...")
plan = build_account_anomaly_plan(client_config)
print_account_anomaly_plan(plan)
json_path, md_path = save_account_anomaly_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(item["campaign_name"] for item in plan.anomalies[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"anomalies": len(plan.anomalies),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu anomalii.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,627 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_ad_asset_statuses"
TASK_NAME = "Sprawdzenie statusow reklam i zasobow"
SCOPE = [
{
"area": "Reklamy",
"check": "Pokaz status reklamy, typ reklamy i status zatwierdzenia polityk dla aktywnych grup reklam.",
},
{
"area": "Odrzucenia i ograniczenia",
"check": "Oznacz reklamy odrzucone, ograniczone, oczekujace albo wstrzymane jako elementy do szybkiej reakcji.",
},
{
"area": "Zasoby PMax",
"check": "Pokaz status zasobow w asset group, jezeli Google Ads API udostepnia te dane dla konta.",
},
{
"area": "Audyt techniczny",
"check": "Oddziel dostepnosc emisji od oceny jakosci tekstow, naglowkow i kreacji.",
},
]
OUT_OF_SCOPE = [
"analiza jakosci tekstow reklam i kreacji",
"budzety i wykorzystanie budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"zapytania uzytkownikow oraz wykluczenia",
"wdrazanie zmian reklam albo zasobow na koncie Google Ads",
]
PROBLEM_APPROVAL_STATUSES = {
"DISAPPROVED",
"AREA_OF_INTEREST_ONLY",
"APPROVED_LIMITED",
"UNDER_REVIEW",
"UNKNOWN",
"UNSPECIFIED",
}
PROBLEM_ENTITY_STATUSES = {
"PAUSED",
"DISABLED",
"UNKNOWN",
"UNSPECIFIED",
}
@dataclass
class AdAssetStatusPlan:
ads: list[dict]
assets: list[dict]
ad_status_summary: list[dict]
approval_summary: list[dict]
asset_status_summary: list[dict]
problem_items: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"ads": self.ads,
"assets": self.assets,
"ad_status_summary": self.ad_status_summary,
"approval_summary": self.approval_summary,
"asset_status_summary": self.asset_status_summary,
"problem_items": self.problem_items,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "AdAssetStatusPlan":
return cls(
ads=data.get("ads", []),
assets=data.get("assets", []),
ad_status_summary=data.get("ad_status_summary", []),
approval_summary=data.get("approval_summary", []),
asset_status_summary=data.get("asset_status_summary", []),
problem_items=data.get("problem_items", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def ad_label(row: dict) -> str:
if row.get("ad_name"):
return row["ad_name"]
return f"{row['ad_type']} {row['ad_id']}"
def item_severity(status: str, approval_status: str) -> str:
if approval_status == "DISAPPROVED":
return "wysokie"
if status in PROBLEM_ENTITY_STATUSES:
return "srednie"
if approval_status in {"APPROVED_LIMITED", "AREA_OF_INTEREST_ONLY"}:
return "srednie"
if approval_status == "UNDER_REVIEW":
return "niskie"
if approval_status in {"UNKNOWN", "UNSPECIFIED"}:
return "niskie"
return "ok"
def item_flags(status: str, approval_status: str) -> list[str]:
flags = []
if status in PROBLEM_ENTITY_STATUSES:
flags.append(f"status: {status}")
if approval_status in PROBLEM_APPROVAL_STATUSES:
flags.append(f"polityka: {approval_status}")
return flags or ["ok"]
def fetch_ads(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
ad_group.id,
ad_group.name,
ad_group.status,
ad_group_ad.ad.id,
ad_group_ad.ad.name,
ad_group_ad.ad.type,
ad_group_ad.status,
ad_group_ad.policy_summary.approval_status
FROM ad_group_ad
WHERE campaign.status != 'REMOVED'
AND ad_group.status != 'REMOVED'
AND ad_group_ad.status != 'REMOVED'
""",
)
ads = []
for row in rows:
ad = row.ad_group_ad.ad
status = enum_name(row.ad_group_ad.status)
approval_status = enum_name(row.ad_group_ad.policy_summary.approval_status)
record = {
"item_type": "reklama",
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"campaign_status": enum_name(row.campaign.status),
"channel_type": enum_name(row.campaign.advertising_channel_type),
"ad_group_id": str(row.ad_group.id),
"ad_group_name": row.ad_group.name,
"ad_group_status": enum_name(row.ad_group.status),
"ad_id": str(ad.id),
"ad_name": str(ad.name or ""),
"ad_type": enum_name(ad.type),
"status": status,
"approval_status": approval_status,
}
record["label"] = ad_label(record)
record["severity"] = item_severity(status, approval_status)
record["flags"] = item_flags(status, approval_status)
ads.append(record)
ads.sort(key=lambda row: (row["severity"], row["campaign_name"], row["ad_group_name"], row["ad_type"]))
return ads
def fetch_asset_group_assets(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
asset_group.id,
asset_group.name,
asset.id,
asset.name,
asset.type,
asset_group_asset.field_type,
asset_group_asset.status,
asset_group_asset.policy_summary.approval_status
FROM asset_group_asset
WHERE campaign.status != 'REMOVED'
AND asset_group_asset.status != 'REMOVED'
""",
)
assets = []
for row in rows:
status = enum_name(row.asset_group_asset.status)
approval_status = enum_name(row.asset_group_asset.policy_summary.approval_status)
record = {
"item_type": "zasob",
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"campaign_status": enum_name(row.campaign.status),
"asset_group_id": str(row.asset_group.id),
"asset_group_name": row.asset_group.name,
"asset_id": str(row.asset.id),
"asset_name": str(row.asset.name or ""),
"asset_type": enum_name(row.asset.type),
"field_type": enum_name(row.asset_group_asset.field_type),
"status": status,
"approval_status": approval_status,
}
record["label"] = record["asset_name"] or f"{record['asset_type']} {record['asset_id']}"
record["severity"] = item_severity(status, approval_status)
record["flags"] = item_flags(status, approval_status)
assets.append(record)
assets.sort(key=lambda row: (row["severity"], row["campaign_name"], row["asset_group_name"], row["field_type"]))
return assets
def build_counter_summary(rows: list[dict], field: str, label: str) -> list[dict]:
counter = Counter(row.get(field, "") or "(brak)" for row in rows)
return [{label: key, "count": value} for key, value in counter.most_common()]
def build_problem_items(ads: list[dict], assets: list[dict]) -> list[dict]:
items = []
for ad in ads:
if ad["flags"] == ["ok"]:
continue
items.append(
{
"item_type": "reklama",
"severity": ad["severity"],
"campaign_name": ad["campaign_name"],
"container": ad["ad_group_name"],
"label": ad["label"],
"status": ad["status"],
"approval_status": ad["approval_status"],
"flags": ad["flags"],
"recommendation": "sprawdz przyczyne ograniczenia lub odrzucenia w Google Ads",
}
)
for asset in assets:
if asset["flags"] == ["ok"]:
continue
items.append(
{
"item_type": "zasob",
"severity": asset["severity"],
"campaign_name": asset["campaign_name"],
"container": asset["asset_group_name"],
"label": asset["label"],
"status": asset["status"],
"approval_status": asset["approval_status"],
"flags": asset["flags"],
"recommendation": "sprawdz status zasobu i polityki w asset group",
}
)
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
items.sort(key=lambda row: (severity_order.get(row["severity"], 9), row["campaign_name"], row["item_type"]))
return items
def build_ad_asset_status_plan(client_config: ClientConfig) -> AdAssetStatusPlan:
warnings = []
try:
ads = fetch_ads(client_config)
except Exception as exc:
ads = []
warnings.append(f"Nie udalo sie pobrac reklam z Google Ads API: {exc}")
try:
assets = fetch_asset_group_assets(client_config)
except Exception as exc:
assets = []
warnings.append(f"Nie udalo sie pobrac zasobow PMax z Google Ads API: {exc}")
if not ads:
warnings.append("Nie znaleziono reklam albo nie udalo sie ich pobrac.")
if not assets:
warnings.append("Nie znaleziono zasobow asset group albo API nie udostepnilo ich dla tego konta.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace odrzucen reklam i zasobow bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return AdAssetStatusPlan(
ads=ads,
assets=assets,
ad_status_summary=build_counter_summary(ads, "status", "status"),
approval_summary=build_counter_summary(ads + assets, "approval_status", "approval_status"),
asset_status_summary=build_counter_summary(assets, "status", "status"),
problem_items=build_problem_items(ads, assets),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_ad_asset_status_plan(domain: str, plan: AdAssetStatusPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie statusow reklam i zasobow",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Reklamy: {len(plan.ads)}",
f"- Zasoby asset group: {len(plan.assets)}",
f"- Elementy do oceny: {len(plan.problem_items)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.problem_items:
lines.extend(
[
"## Elementy do oceny",
"",
"| Waznosc | Typ | Kampania | Kontener | Element | Status | Polityka | Flagi | Rekomendacja |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.problem_items:
lines.append(
f"| {item['severity']} | {item['item_type']} | {md_cell(item['campaign_name'])} | "
f"{md_cell(item['container'])} | {md_cell(item['label'])} | {item['status']} | "
f"{item['approval_status']} | {md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
)
lines.append("")
if plan.ad_status_summary:
lines.extend(["## Statusy reklam", "", "| Status | Liczba |", "| --- | --- |"])
for row in plan.ad_status_summary:
lines.append(f"| {row['status']} | {row['count']} |")
lines.append("")
if plan.approval_summary:
lines.extend(["## Statusy zatwierdzenia", "", "| Status polityki | Liczba |", "| --- | --- |"])
for row in plan.approval_summary:
lines.append(f"| {row['approval_status']} | {row['count']} |")
lines.append("")
if plan.ads:
lines.extend(
[
"## Reklamy",
"",
"| Kampania | Grupa reklam | Typ | Status | Polityka | Flagi |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for ad in plan.ads:
lines.append(
f"| {md_cell(ad['campaign_name'])} | {md_cell(ad['ad_group_name'])} | {ad['ad_type']} | "
f"{ad['status']} | {ad['approval_status']} | {md_cell(', '.join(ad['flags']))} |"
)
lines.append("")
if plan.assets:
lines.extend(
[
"## Zasoby asset group",
"",
"| Kampania | Asset group | Typ | Pole | Status | Polityka | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
)
for asset in plan.assets:
lines.append(
f"| {md_cell(asset['campaign_name'])} | {md_cell(asset['asset_group_name'])} | {asset['asset_type']} | "
f"{asset['field_type']} | {asset['status']} | {asset['approval_status']} | {md_cell(', '.join(asset['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_ad_asset_status_plan(plan: AdAssetStatusPlan) -> None:
print("\nPlan sprawdzenia statusow reklam i zasobow")
print_table(
["Metryka", "Liczba"],
[
["Reklamy", str(len(plan.ads))],
["Zasoby asset group", str(len(plan.assets))],
["Elementy do oceny", str(len(plan.problem_items))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.problem_items:
print("\nElementy do oceny")
print_table(
["Nr", "Waznosc", "Typ", "Kampania", "Kontener", "Status", "Polityka", "Flagi"],
[
[
str(index),
item["severity"],
item["item_type"],
item["campaign_name"],
item["container"],
item["status"],
item["approval_status"],
", ".join(item["flags"]),
]
for index, item in enumerate(plan.problem_items[:30], 1)
],
)
if len(plan.problem_items) > 30:
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
if plan.ad_status_summary:
print("\nStatusy reklam")
print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.ad_status_summary])
if plan.approval_summary:
print("\nStatusy zatwierdzenia")
print_table(
["Status polityki", "Liczba"],
[[row["approval_status"], str(row["count"])] for row in plan.approval_summary],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_ad_asset_status_plan(
client_config: ClientConfig,
plan: AdAssetStatusPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem statusow reklam i zasobow i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
"summary": {
"ads": len(plan.ads),
"assets": len(plan.assets),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_ad_asset_statuses(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = AdAssetStatusPlan.from_dict(plan_data)
print_ad_asset_status_plan(plan)
apply_ad_asset_status_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia statusow reklam i zasobow...")
plan = build_ad_asset_status_plan(client_config)
print_ad_asset_status_plan(plan)
json_path, md_path = save_ad_asset_status_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
"summary": {
"ads": len(plan.ads),
"assets": len(plan.assets),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu statusow reklam i zasobow.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,564 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_ad_schedules"
TASK_NAME = "Sprawdzenie harmonogramu reklam"
SCOPE = [
{
"area": "Harmonogram 24/7",
"check": "Oznacz kampanie bez jawnego harmonogramu jako dzialajace caly tydzien i caly dzien.",
},
{
"area": "Niestandardowe godziny",
"check": "Wypisz kampanie z ustawionymi przedzialami dni i godzin emisji.",
},
{
"area": "Typ kampanii",
"check": "Pokaz harmonogram razem z typem kampanii, zeby osobno oceniac Search, Shopping i PMax.",
},
{
"area": "Audyt ustawien",
"check": "Przygotuj szybki przeglad harmonogramow, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
},
]
OUT_OF_SCOPE = [
"budzety i wykorzystanie budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy RSA, assety i kreacje",
"wdrazanie zmian harmonogramu na koncie Google Ads",
]
DAY_ORDER = {
"MONDAY": 1,
"TUESDAY": 2,
"WEDNESDAY": 3,
"THURSDAY": 4,
"FRIDAY": 5,
"SATURDAY": 6,
"SUNDAY": 7,
}
DAY_LABELS = {
"MONDAY": "poniedzialek",
"TUESDAY": "wtorek",
"WEDNESDAY": "sroda",
"THURSDAY": "czwartek",
"FRIDAY": "piatek",
"SATURDAY": "sobota",
"SUNDAY": "niedziela",
}
MINUTE_LABELS = {
"ZERO": "00",
"FIFTEEN": "15",
"THIRTY": "30",
"FORTY_FIVE": "45",
}
@dataclass
class AdSchedulePlan:
campaigns: list[dict]
schedule_summary: list[dict]
channel_summary: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"schedule_summary": self.schedule_summary,
"channel_summary": self.channel_summary,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "AdSchedulePlan":
return cls(
campaigns=data.get("campaigns", []),
schedule_summary=data.get("schedule_summary", []),
channel_summary=data.get("channel_summary", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def minute_label(value: Any) -> str:
raw = enum_name(value)
return MINUTE_LABELS.get(raw, raw)
def hour_label(hour: int, minute: str) -> str:
return f"{int(hour):02d}:{minute}"
def schedule_label(schedule: dict) -> str:
day = DAY_LABELS.get(schedule["day_of_week"], schedule["day_of_week"])
start = hour_label(schedule["start_hour"], schedule["start_minute"])
end = hour_label(schedule["end_hour"], schedule["end_minute"])
return f"{day} {start}-{end}"
def join_schedule_labels(schedules: list[dict], limit: int = 8) -> str:
if not schedules:
return "24/7 (brak jawnego harmonogramu)"
labels = [schedule["label"] for schedule in schedules]
shown = labels[:limit]
if len(labels) > limit:
shown.append(f"... +{len(labels) - limit}")
return ", ".join(shown)
def campaign_flags(campaign: dict) -> list[str]:
schedules = campaign["schedules"]
flags = []
if not schedules:
flags.append("dziala 24/7")
if len(schedules) == 7 and all(
item["start_hour"] == 0
and item["start_minute"] == "00"
and item["end_hour"] == 24
and item["end_minute"] == "00"
for item in schedules
):
flags.append("harmonogram rowny 24/7")
if schedules and len(schedules) < 5:
flags.append("malo dni emisji")
if any(item["start_hour"] < 6 or item["end_hour"] > 22 for item in schedules):
flags.append("emisja nocna do oceny")
return flags or ["ok"]
def fetch_campaigns(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type
FROM campaign
WHERE campaign.status != 'REMOVED'
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
campaigns.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"schedules": [],
}
)
return campaigns
def fetch_ad_schedules(client_config: ClientConfig) -> dict[str, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign_criterion.criterion_id,
campaign_criterion.status,
campaign_criterion.ad_schedule.day_of_week,
campaign_criterion.ad_schedule.start_hour,
campaign_criterion.ad_schedule.start_minute,
campaign_criterion.ad_schedule.end_hour,
campaign_criterion.ad_schedule.end_minute
FROM campaign_criterion
WHERE campaign.status != 'REMOVED'
AND campaign_criterion.status != 'REMOVED'
AND campaign_criterion.type = 'AD_SCHEDULE'
""",
)
schedules_by_campaign: dict[str, list[dict]] = {}
for row in rows:
campaign_id = str(row.campaign.id)
criterion = row.campaign_criterion
schedule = criterion.ad_schedule
record = {
"criterion_id": str(criterion.criterion_id),
"status": enum_name(criterion.status),
"day_of_week": enum_name(schedule.day_of_week),
"start_hour": int(schedule.start_hour or 0),
"start_minute": minute_label(schedule.start_minute),
"end_hour": int(schedule.end_hour or 0),
"end_minute": minute_label(schedule.end_minute),
}
record["label"] = schedule_label(record)
schedules_by_campaign.setdefault(campaign_id, []).append(record)
for schedules in schedules_by_campaign.values():
schedules.sort(key=lambda item: (DAY_ORDER.get(item["day_of_week"], 99), item["start_hour"], item["start_minute"]))
return schedules_by_campaign
def attach_schedules(campaigns: list[dict], schedules_by_campaign: dict[str, list[dict]]) -> list[dict]:
for campaign in campaigns:
campaign["schedules"] = schedules_by_campaign.get(campaign["campaign_id"], [])
campaign["schedule_count"] = len(campaign["schedules"])
campaign["schedule_label"] = join_schedule_labels(campaign["schedules"])
campaign["flags"] = campaign_flags(campaign)
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
return campaigns
def build_schedule_summary(campaigns: list[dict]) -> list[dict]:
return [
{
"metric": "Kampanie",
"count": len(campaigns),
},
{
"metric": "Kampanie bez jawnego harmonogramu",
"count": sum(1 for campaign in campaigns if campaign["schedule_count"] == 0),
},
{
"metric": "Kampanie z harmonogramem",
"count": sum(1 for campaign in campaigns if campaign["schedule_count"] > 0),
},
{
"metric": "Kampanie z emisja nocna do oceny",
"count": sum(1 for campaign in campaigns if "emisja nocna do oceny" in campaign["flags"]),
},
{
"metric": "Kampanie z malą liczba dni emisji",
"count": sum(1 for campaign in campaigns if "malo dni emisji" in campaign["flags"]),
},
]
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
counter = Counter(row["channel_type"] for row in campaigns)
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
def build_ad_schedule_plan(client_config: ClientConfig) -> AdSchedulePlan:
warnings = []
try:
campaigns = fetch_campaigns(client_config)
schedules_by_campaign = fetch_ad_schedules(client_config)
campaigns = attach_schedules(campaigns, schedules_by_campaign)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac harmonogramow reklam z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac harmonogramow.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace harmonogramow reklam bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return AdSchedulePlan(
campaigns=campaigns,
schedule_summary=build_schedule_summary(campaigns),
channel_summary=build_channel_summary(campaigns),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_ad_schedule_plan(domain: str, plan: AdSchedulePlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie harmonogramu reklam",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.schedule_summary:
lines.extend(["## Podsumowanie harmonogramow", "", "| Metryka | Liczba |", "| --- | --- |"])
for row in plan.schedule_summary:
lines.append(f"| {md_cell(row['metric'])} | {row['count']} |")
lines.append("")
if plan.channel_summary:
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
for row in plan.channel_summary:
lines.append(f"| {row['channel_type']} | {row['count']} |")
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie",
"",
"| Kampania | Typ | Status | Harmonogram | Flagi |",
"| --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
f"{md_cell(campaign['schedule_label'])} | {md_cell(', '.join(campaign['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_ad_schedule_plan(plan: AdSchedulePlan) -> None:
print("\nPlan sprawdzenia harmonogramu reklam")
print_table(
["Metryka", "Liczba"],
[
["Kampanie", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.schedule_summary:
print("\nPodsumowanie harmonogramow")
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.schedule_summary])
if plan.channel_summary:
print("\nPodsumowanie po typach kampanii")
print_table(
["Typ", "Liczba"],
[[row["channel_type"], str(row["count"])] for row in plan.channel_summary],
)
if plan.campaigns:
print("\nKampanie")
print_table(
["Nr", "Kampania", "Typ", "Liczba przedz.", "Harmonogram", "Flagi"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
str(campaign["schedule_count"]),
campaign["schedule_label"],
", ".join(campaign["flags"]),
]
for index, campaign in enumerate(plan.campaigns[:30], 1)
],
)
if len(plan.campaigns) > 30:
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_ad_schedule_plan(
client_config: ClientConfig,
plan: AdSchedulePlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem harmonogramu reklam i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_ad_schedules(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = AdSchedulePlan.from_dict(plan_data)
print_ad_schedule_plan(plan)
apply_ad_schedule_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia harmonogramu reklam...")
plan = build_ad_schedule_plan(client_config)
print_ad_schedule_plan(plan)
json_path, md_path = save_ad_schedule_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu harmonogramu reklam.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,994 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
OUT_OF_SCOPE_COMMON = [
"wdrazanie zmian na koncie Google Ads",
"automatyczne zmiany budzetow",
"automatyczne zmiany stawek albo strategii ustalania stawek",
"automatyczne wylaczanie kampanii, grup reklam, slow kluczowych, reklam albo produktow",
]
@dataclass(frozen=True)
class AuditDefinition:
task_id: str
task_name: str
intro: str
query: str
row_builder: Callable[[Any], dict]
summary_fields: list[str]
table_fields: list[str]
scope: list[dict]
out_of_scope: list[str]
sort_key: Callable[[dict], tuple]
finding_builder: Callable[[dict], list[str]]
@dataclass
class GenericAuditPlan:
task: str
task_name: str
rows: list[dict]
findings: list[dict]
summary: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": self.task,
"task_name": self.task_name,
"rows": self.rows,
"findings": self.findings,
"summary": self.summary,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "GenericAuditPlan":
return cls(
task=data.get("task", ""),
task_name=data.get("task_name", ""),
rows=data.get("rows", []),
findings=data.get("findings", []),
summary=data.get("summary", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def money_micros(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def money_amount(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{float(value or 0):.2f}{suffix}"
def decimal(value: int | float, places: int = 2) -> str:
return f"{float(value or 0):.{places}f}"
def percent(numerator: int | float, denominator: int | float) -> float:
if not denominator:
return 0.0
return round((float(numerator) / float(denominator)) * 100, 1)
def rate(numerator: int | float, denominator: int | float) -> float:
if not denominator:
return 0.0
return round((float(numerator) / float(denominator)) * 100, 2)
def roas(conversion_value: float, cost_micros: int) -> float:
cost = micros_to_amount(cost_micros)
if not cost:
return 0.0
return round(float(conversion_value or 0) / cost, 2)
def cpa(cost_micros: int, conversions: float) -> float:
if not conversions:
return 0.0
return round(micros_to_amount(cost_micros) / float(conversions), 2)
def safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def safe_float(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def base_metrics(row: Any, currency_code: str) -> dict:
metrics = row.metrics
cost_micros = safe_int(metrics.cost_micros)
conversions = safe_float(metrics.conversions)
conversion_value = safe_float(metrics.conversions_value)
clicks = safe_int(metrics.clicks)
impressions = safe_int(metrics.impressions)
return {
"impressions": impressions,
"clicks": clicks,
"cost": money_micros(cost_micros, currency_code),
"cost_micros": cost_micros,
"conversions": round(conversions, 2),
"conversion_value": money_amount(conversion_value, currency_code),
"conversion_value_raw": round(conversion_value, 2),
"ctr": f"{rate(clicks, impressions):.2f}%",
"avg_cpc": money_micros(int(cost_micros / clicks) if clicks else 0, currency_code),
"conversion_rate": f"{rate(conversions, clicks):.2f}%",
"roas": roas(conversion_value, cost_micros),
"cpa": money_amount(cpa(cost_micros, conversions), currency_code),
}
def row_campaign_day(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"channel": enum_name(row.campaign.advertising_channel_type),
"day_of_week": enum_name(row.segments.day_of_week),
**base_metrics(row, currency_code),
}
def row_campaign_hour(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"channel": enum_name(row.campaign.advertising_channel_type),
"hour": str(row.segments.hour),
**base_metrics(row, currency_code),
}
def row_campaign_network(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"channel": enum_name(row.campaign.advertising_channel_type),
"network": enum_name(row.segments.ad_network_type),
**base_metrics(row, currency_code),
}
def row_ad_group(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"ad_group": row.ad_group.name,
"status": enum_name(row.ad_group.status),
"type": enum_name(row.ad_group.type_),
**base_metrics(row, currency_code),
}
def row_keyword_quality(row: Any, currency_code: str) -> dict:
criterion = row.ad_group_criterion
quality = criterion.quality_info
return {
"campaign": row.campaign.name,
"ad_group": row.ad_group.name,
"keyword": criterion.keyword.text,
"match_type": enum_name(criterion.keyword.match_type),
"status": enum_name(criterion.status),
"quality_score": safe_int(quality.quality_score),
"creative_quality": enum_name(quality.creative_quality_score),
"landing_page_quality": enum_name(quality.post_click_quality_score),
"expected_ctr": enum_name(quality.search_predicted_ctr),
**base_metrics(row, currency_code),
}
def row_landing_page(row: Any, currency_code: str) -> dict:
return {
"landing_page": row.landing_page_view.unexpanded_final_url,
**base_metrics(row, currency_code),
}
def row_conversion_action(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"conversion_action": str(row.segments.conversion_action_name or ""),
"conversion_category": enum_name(row.segments.conversion_action_category),
**base_metrics(row, currency_code),
}
def row_shopping_product(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"ad_group": row.ad_group.name,
"product_title": row.segments.product_title,
"item_id": row.segments.product_item_id,
"brand": row.segments.product_brand,
"category_l1": row.segments.product_category_level1,
**base_metrics(row, currency_code),
}
def row_gender(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"ad_group": row.ad_group.name,
"gender": enum_name(row.ad_group_criterion.gender.type_),
**base_metrics(row, currency_code),
}
def row_age_range(row: Any, currency_code: str) -> dict:
return {
"campaign": row.campaign.name,
"ad_group": row.ad_group.name,
"age_range": enum_name(row.ad_group_criterion.age_range.type_),
**base_metrics(row, currency_code),
}
def generic_findings(row: dict) -> list[str]:
flags = []
if row.get("cost_micros", 0) >= 100_000_000 and row.get("conversions", 0) == 0:
flags.append("istotny koszt bez konwersji")
if row.get("clicks", 0) >= 50 and row.get("conversions", 0) == 0:
flags.append("wiele klikniec bez konwersji")
if row.get("roas", 0) > 0 and row.get("roas", 0) < 1 and row.get("cost_micros", 0) >= 100_000_000:
flags.append("niski ROAS przy istotnym koszcie")
if row.get("impressions", 0) >= 1000 and row.get("clicks", 0) == 0:
flags.append("wyswietlenia bez klikniec")
return flags
def quality_findings(row: dict) -> list[str]:
flags = generic_findings(row)
if 0 < row.get("quality_score", 0) <= 3:
flags.append("niski Wynik Jakosci")
if row.get("landing_page_quality") in {"BELOW_AVERAGE", "BELOW AVERAGE"}:
flags.append("slaba jakosc strony docelowej")
if row.get("expected_ctr") in {"BELOW_AVERAGE", "BELOW AVERAGE"}:
flags.append("niski przewidywany CTR")
return flags
def landing_page_findings(row: dict) -> list[str]:
flags = generic_findings(row)
if not row.get("landing_page"):
flags.append("brak widocznego URL strony docelowej")
return flags
def build_summary(rows: list[dict], summary_fields: list[str], currency_code: str) -> list[dict]:
summary = []
for field in summary_fields:
buckets: dict[str, dict] = {}
for row in rows:
key = str(row.get(field, "") or "(brak)")
bucket = buckets.setdefault(
key,
{
"segment": key,
"rows": 0,
"cost_micros": 0,
"clicks": 0,
"impressions": 0,
"conversions": 0.0,
"conversion_value_raw": 0.0,
},
)
bucket["rows"] += 1
bucket["cost_micros"] += safe_int(row.get("cost_micros"))
bucket["clicks"] += safe_int(row.get("clicks"))
bucket["impressions"] += safe_int(row.get("impressions"))
bucket["conversions"] += safe_float(row.get("conversions"))
bucket["conversion_value_raw"] += safe_float(row.get("conversion_value_raw"))
for bucket in buckets.values():
summary.append(
{
"group_by": field,
"segment": bucket["segment"],
"rows": bucket["rows"],
"cost": money_micros(bucket["cost_micros"], currency_code),
"clicks": bucket["clicks"],
"conversions": round(bucket["conversions"], 2),
"conversion_value": money_amount(bucket["conversion_value_raw"], currency_code),
"roas": roas(bucket["conversion_value_raw"], bucket["cost_micros"]),
}
)
summary.sort(key=lambda row: (row["group_by"], -safe_float(str(row["cost"]).split()[0].replace(",", "."))))
return summary
def build_plan(definition: AuditDefinition, client_config: ClientConfig) -> GenericAuditPlan:
warnings = []
currency_code = ""
rows: list[dict] = []
try:
google_client = get_google_ads_client(use_proto_plus=True)
currency_code = fetch_currency_code(google_client, client_config.safe_customer_id)
api_rows = run_query(google_client, client_config.safe_customer_id, definition.query)
rows = [definition.row_builder(row, currency_code) for row in api_rows]
except Exception as exc:
warnings.append(f"Nie udalo sie pobrac danych dla zadania {definition.task_name}: {compact_error(exc)}")
rows.sort(key=definition.sort_key)
findings = []
for row in rows:
flags = definition.finding_builder(row)
if flags:
findings.append(
{
**{key: row.get(key, "") for key in definition.table_fields if key in row},
"flags": flags,
"recommendation": "sprawdz ten segment przed decyzja optymalizacyjna",
}
)
if not rows:
warnings.append("Nie znaleziono danych w analizowanym zakresie albo API nie zwrocilo wynikow.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(definition.task_id)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return GenericAuditPlan(
task=definition.task_id,
task_name=definition.task_name,
rows=rows,
findings=findings,
summary=build_summary(rows, definition.summary_fields, currency_code),
scope=definition.scope,
out_of_scope=definition.out_of_scope,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def compact_error(exc: Exception) -> str:
message = str(exc)
if "PROHIBITED" in message:
return "Google Ads API odrzucilo kombinacje pol w zapytaniu. Szczegoly sa w logu requestu."
if "UNRECOGNIZED_FIELD" in message:
return "Google Ads API nie rozpoznalo jednego z pol zapytania."
if "PERMISSION_DENIED" in message:
return "brak uprawnien do pobrania tych danych."
return message.splitlines()[0][:500]
def save_plan(domain: str, definition: AuditDefinition, plan: GenericAuditPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{definition.task_id}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
f"# Plan: {definition.task_name}",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Wiersze danych: {len(plan.rows)}",
f"- Elementy do oceny: {len(plan.findings)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.summary:
lines.extend(["## Podsumowanie segmentow", "", "| Grupowanie | Segment | Wiersze | Koszt | Klikniecia | Konwersje | Wartosc | ROAS |", "| --- | --- | --- | --- | --- | --- | --- | --- |"])
for row in plan.summary:
lines.append(
f"| {md_cell(row['group_by'])} | {md_cell(row['segment'])} | {row['rows']} | {row['cost']} | "
f"{row['clicks']} | {row['conversions']} | {row['conversion_value']} | {row['roas']} |"
)
lines.append("")
if plan.findings:
fields = [field for field in definition.table_fields if any(field in row for row in plan.findings)]
lines.extend(["## Elementy do oceny", "", "| " + " | ".join(fields + ["Flagi", "Rekomendacja"]) + " |", "| " + " | ".join("---" for _ in fields + ["Flagi", "Rekomendacja"]) + " |"])
for row in plan.findings:
values = [md_cell(row.get(field, "")) for field in fields]
values.extend([md_cell(", ".join(row.get("flags", []))), md_cell(row.get("recommendation", ""))])
lines.append("| " + " | ".join(values) + " |")
lines.append("")
if plan.rows:
fields = [field for field in definition.table_fields if any(field in row for row in plan.rows)]
lines.extend(["## Dane", "", "| " + " | ".join(fields) + " |", "| " + " | ".join("---" for _ in fields) + " |"])
for row in plan.rows:
lines.append("| " + " | ".join(md_cell(row.get(field, "")) for field in fields) + " |")
lines.append("")
if plan.knowledge_rules:
lines.extend(["## Reguly z bazy wiedzy", "", "| ID | Temat | Rekomendacja | Ryzyko |", "| --- | --- | --- | --- |"])
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_plan(definition: AuditDefinition, plan: GenericAuditPlan) -> None:
print(f"\nPlan: {definition.task_name}")
print_table(
["Metryka", "Liczba"],
[
["Wiersze danych", str(len(plan.rows))],
["Elementy do oceny", str(len(plan.findings))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.summary:
print("\nPodsumowanie segmentow")
print_table(
["Grupowanie", "Segment", "Wiersze", "Koszt", "Klikniecia", "Konw.", "Wartosc", "ROAS"],
[
[row["group_by"], row["segment"], str(row["rows"]), row["cost"], str(row["clicks"]), str(row["conversions"]), row["conversion_value"], str(row["roas"])]
for row in plan.summary[:30]
],
)
if len(plan.summary) > 30:
print(f"... oraz {len(plan.summary) - 30} kolejnych segmentow w pliku planu")
if plan.findings:
fields = [field for field in definition.table_fields if any(field in row for row in plan.findings)]
rows = []
for index, row in enumerate(plan.findings[:30], 1):
rows.append([str(index)] + [row.get(field, "") for field in fields] + [", ".join(row.get("flags", []))])
print("\nElementy do oceny")
print_table(["Nr"] + fields + ["Flagi"], rows)
if len(plan.findings) > 30:
print(f"... oraz {len(plan.findings) - 30} kolejnych elementow w pliku planu")
elif plan.rows:
fields = [field for field in definition.table_fields if any(field in row for row in plan.rows)]
print("\nDane")
print_table(
["Nr"] + fields,
[[str(index)] + [row.get(field, "") for field in fields] for index, row in enumerate(plan.rows[:30], 1)],
)
if len(plan.rows) > 30:
print(f"... oraz {len(plan.rows) - 30} kolejnych wierszy w pliku planu")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_plan(client_config: ClientConfig, plan: GenericAuditPlan, show_navigation: bool = True) -> None:
print(f"\nTo zadanie jest audytem: {plan.task_name}. Nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, plan.task_name, [])
history_path = append_history(
client_config.domain,
{
"task": plan.task_name,
"status": "audyt oznaczony jako wykonany",
"summary": {
"rows": len(plan.rows),
"findings": len(plan.findings),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_generic_audit(
definition: AuditDefinition,
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = GenericAuditPlan.from_dict(plan_data)
print_plan(definition, plan)
apply_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print(definition.intro)
plan = build_plan(definition, client_config)
print_plan(definition, plan)
json_path, md_path = save_plan(client_config.domain, definition, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": definition.task_name,
"status": "plan przygotowany",
"summary": {
"rows": len(plan.rows),
"findings": len(plan.findings),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu.")
if show_navigation:
print_next_navigation(client_config.domain)
def scope(*items: tuple[str, str]) -> list[dict]:
return [{"area": area, "check": check} for area, check in items]
def query_campaign_segment(segment_field: str) -> str:
return f"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
{segment_field},
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
"""
QUERY_AD_GROUP = """
SELECT
campaign.name,
ad_group.name,
ad_group.status,
ad_group.type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM ad_group
WHERE campaign.status != 'REMOVED'
AND ad_group.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
"""
QUERY_KEYWORD_QUALITY = """
SELECT
campaign.name,
ad_group.name,
ad_group_criterion.status,
ad_group_criterion.keyword.text,
ad_group_criterion.keyword.match_type,
ad_group_criterion.quality_info.quality_score,
ad_group_criterion.quality_info.creative_quality_score,
ad_group_criterion.quality_info.post_click_quality_score,
ad_group_criterion.quality_info.search_predicted_ctr,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM keyword_view
WHERE campaign.status != 'REMOVED'
AND ad_group.status != 'REMOVED'
AND ad_group_criterion.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
"""
QUERY_LANDING_PAGE = """
SELECT
landing_page_view.unexpanded_final_url,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM landing_page_view
WHERE segments.date DURING LAST_30_DAYS
"""
QUERY_CONVERSION_ACTION = """
SELECT
campaign.name,
segments.conversion_action_name,
segments.conversion_action_category,
metrics.conversions,
metrics.conversions_value
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
"""
QUERY_SHOPPING_PRODUCT = """
SELECT
campaign.name,
ad_group.name,
segments.product_title,
segments.product_item_id,
segments.product_brand,
segments.product_category_level1,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date DURING LAST_30_DAYS
LIMIT 500
"""
QUERY_GENDER = """
SELECT
campaign.name,
ad_group.name,
ad_group_criterion.gender.type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM gender_view
WHERE segments.date DURING LAST_30_DAYS
"""
QUERY_AGE_RANGE = """
SELECT
campaign.name,
ad_group.name,
ad_group_criterion.age_range.type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM age_range_view
WHERE segments.date DURING LAST_30_DAYS
"""
DEFINITIONS: dict[str, AuditDefinition] = {
"check_day_of_week_performance": AuditDefinition(
task_id="check_day_of_week_performance",
task_name="Sprawdzenie dni tygodnia",
intro="Przygotowuje plan sprawdzenia wynikow wedlug dni tygodnia...",
query=query_campaign_segment("segments.day_of_week"),
row_builder=row_campaign_day,
summary_fields=["day_of_week"],
table_fields=["campaign", "channel", "day_of_week", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
scope=scope(
("Dni tygodnia", "Porownaj koszt, klikniecia, konwersje, wartosc konwersji, ROAS i CPA wedlug dnia tygodnia."),
("Kontekst kampanii", "Pokaz dane w podziale na kampanie, aby nie mieszac roznych typow ruchu."),
("Decyzja reczna", "Zaznacz segmenty do oceny bez zmiany harmonogramu reklam."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany harmonogramu reklam"],
sort_key=lambda row: (row["campaign"], row["day_of_week"]),
finding_builder=generic_findings,
),
"check_hour_of_day_performance": AuditDefinition(
task_id="check_hour_of_day_performance",
task_name="Sprawdzenie godzin dnia",
intro="Przygotowuje plan sprawdzenia wynikow wedlug godzin dnia...",
query=query_campaign_segment("segments.hour"),
row_builder=row_campaign_hour,
summary_fields=["hour"],
table_fields=["campaign", "channel", "hour", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
scope=scope(
("Godziny dnia", "Porownaj wyniki wedlug godzin z ostatnich 30 dni."),
("Efektywnosc", "Pokaz ROAS, CPA i konwersje dla godzin, w ktorych realnie wydawany jest budzet."),
("Granica zadania", "Nie zmieniaj harmonogramu reklam ani stawek godzinowych w tym zadaniu."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany harmonogramu reklam"],
sort_key=lambda row: (safe_int(row["hour"]), row["campaign"]),
finding_builder=generic_findings,
),
"check_network_performance": AuditDefinition(
task_id="check_network_performance",
task_name="Sprawdzenie efektywnosci sieci",
intro="Przygotowuje plan sprawdzenia wynikow wedlug sieci...",
query=query_campaign_segment("segments.ad_network_type"),
row_builder=row_campaign_network,
summary_fields=["network", "channel"],
table_fields=["campaign", "channel", "network", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
scope=scope(
("Sieci", "Porownaj wyniki Google Search, partnerow, Display, Shopping i innych sieci zwracanych przez API."),
("Jakosc ruchu", "Oznacz sieci z kosztem bez konwersji albo slabym ROAS."),
("Granica zadania", "Nie zmieniaj ustawien sieci w tym audycie."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany ustawien sieci kampanii"],
sort_key=lambda row: (row["campaign"], row["network"]),
finding_builder=generic_findings,
),
"check_ad_group_performance": AuditDefinition(
task_id="check_ad_group_performance",
task_name="Sprawdzenie grup reklam",
intro="Przygotowuje plan sprawdzenia wynikow grup reklam...",
query=QUERY_AD_GROUP,
row_builder=row_ad_group,
summary_fields=["campaign", "type"],
table_fields=["campaign", "ad_group", "status", "type", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
scope=scope(
("Grupy reklam", "Pokaz koszt, konwersje, wartosc, ROAS i CPA na poziomie grupy reklam."),
("Priorytet oceny", "Oznacz grupy z kosztem bez konwersji, wieloma kliknieciami bez konwersji albo slabym ROAS."),
("Granica zadania", "Nie wstrzymuj grup reklam automatycznie."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany struktury grup reklam"],
sort_key=lambda row: (-row["cost_micros"], row["campaign"], row["ad_group"]),
finding_builder=generic_findings,
),
"check_keyword_quality_score": AuditDefinition(
task_id="check_keyword_quality_score",
task_name="Sprawdzenie Wyniku Jakosci slow kluczowych",
intro="Przygotowuje plan sprawdzenia Wyniku Jakosci slow kluczowych...",
query=QUERY_KEYWORD_QUALITY,
row_builder=row_keyword_quality,
summary_fields=["quality_score", "match_type"],
table_fields=["campaign", "ad_group", "keyword", "match_type", "quality_score", "creative_quality", "landing_page_quality", "expected_ctr", "cost", "clicks", "conversions", "roas"],
scope=scope(
("Wynik Jakosci", "Sprawdz quality score oraz skladowe: reklama, strona docelowa i przewidywany CTR."),
("Koszt i wynik", "Polacz ocene jakosci z kosztem, kliknieciami, konwersjami i ROAS."),
("Granica zadania", "Nie pauzuj slow kluczowych i nie zmieniaj stawek."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["dodawanie albo usuwanie slow kluczowych"],
sort_key=lambda row: (row.get("quality_score", 0) or 99, -row["cost_micros"], row["keyword"]),
finding_builder=quality_findings,
),
"check_landing_page_performance": AuditDefinition(
task_id="check_landing_page_performance",
task_name="Sprawdzenie stron docelowych",
intro="Przygotowuje plan sprawdzenia stron docelowych...",
query=QUERY_LANDING_PAGE,
row_builder=row_landing_page,
summary_fields=[],
table_fields=["landing_page", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr", "conversion_rate"],
scope=scope(
("Strony docelowe", "Pokaz wyniki URL-i docelowych z ostatnich 30 dni."),
("Efektywnosc", "Oznacz strony z kosztem, kliknieciami i brakiem konwersji albo niskim ROAS."),
("Granica zadania", "Nie zmieniaj URL-i ani reklam automatycznie."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany URL-i docelowych"],
sort_key=lambda row: (-row["cost_micros"], row["landing_page"]),
finding_builder=landing_page_findings,
),
"check_conversion_action_performance": AuditDefinition(
task_id="check_conversion_action_performance",
task_name="Sprawdzenie akcji konwersji",
intro="Przygotowuje plan sprawdzenia akcji konwersji...",
query=QUERY_CONVERSION_ACTION,
row_builder=row_conversion_action,
summary_fields=["conversion_action", "conversion_category"],
table_fields=["campaign", "conversion_action", "conversion_category", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa"],
scope=scope(
("Akcje konwersji", "Pokaz, ktore akcje konwersji generuja wynik w kampaniach."),
("Jakosc pomiaru", "Pomoz wychwycic kampanie optymalizowane na niewlasciwy albo podejrzany typ konwersji."),
("Granica zadania", "Nie zmieniaj ustawien konwersji ani celow kampanii."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany ustawien konwersji"],
sort_key=lambda row: (row["campaign"], row["conversion_action"]),
finding_builder=generic_findings,
),
"check_shopping_product_performance": AuditDefinition(
task_id="check_shopping_product_performance",
task_name="Sprawdzenie wynikow produktow Shopping",
intro="Przygotowuje plan sprawdzenia wynikow produktow Shopping...",
query=QUERY_SHOPPING_PRODUCT,
row_builder=row_shopping_product,
summary_fields=["brand", "category_l1"],
table_fields=["campaign", "ad_group", "product_title", "item_id", "brand", "category_l1", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa"],
scope=scope(
("Produkty", "Pokaz produkty z kosztem, kliknieciami, konwersjami, wartoscia i ROAS."),
("Priorytet feedu", "Wskaz produkty, ktore moga wymagac analizy ceny, tytulu, feedu albo dostepnosci."),
("Granica zadania", "Nie zmieniaj feedu, stawek ani struktury kampanii."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["optymalizacja tytulow, kategorii Google i unit pricing"],
sort_key=lambda row: (-row["cost_micros"], row["product_title"]),
finding_builder=generic_findings,
),
"check_gender_performance": AuditDefinition(
task_id="check_gender_performance",
task_name="Sprawdzenie plci odbiorcow",
intro="Przygotowuje plan sprawdzenia wynikow wedlug plci odbiorcow...",
query=QUERY_GENDER,
row_builder=row_gender,
summary_fields=["gender"],
table_fields=["campaign", "ad_group", "gender", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
scope=scope(
("Plec odbiorcow", "Porownaj wyniki segmentow plci z ostatnich 30 dni."),
("Efektywnosc", "Pokaz koszt, konwersje, wartosc, ROAS i CPA."),
("Granica zadania", "Nie dodawaj wykluczen demograficznych ani korekt stawek."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany kierowania demograficznego"],
sort_key=lambda row: (row["campaign"], row["gender"]),
finding_builder=generic_findings,
),
"check_age_performance": AuditDefinition(
task_id="check_age_performance",
task_name="Sprawdzenie wieku odbiorcow",
intro="Przygotowuje plan sprawdzenia wynikow wedlug wieku odbiorcow...",
query=QUERY_AGE_RANGE,
row_builder=row_age_range,
summary_fields=["age_range"],
table_fields=["campaign", "ad_group", "age_range", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"],
scope=scope(
("Wiek odbiorcow", "Porownaj wyniki przedzialow wieku z ostatnich 30 dni."),
("Efektywnosc", "Pokaz koszt, konwersje, wartosc, ROAS i CPA."),
("Granica zadania", "Nie dodawaj wykluczen demograficznych ani korekt stawek."),
),
out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany kierowania demograficznego"],
sort_key=lambda row: (row["campaign"], row["age_range"]),
finding_builder=generic_findings,
),
}
def run_check_day_of_week_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_day_of_week_performance"], *args, **kwargs)
def run_check_hour_of_day_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_hour_of_day_performance"], *args, **kwargs)
def run_check_network_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_network_performance"], *args, **kwargs)
def run_check_ad_group_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_ad_group_performance"], *args, **kwargs)
def run_check_keyword_quality_score(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_keyword_quality_score"], *args, **kwargs)
def run_check_landing_page_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_landing_page_performance"], *args, **kwargs)
def run_check_conversion_action_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_conversion_action_performance"], *args, **kwargs)
def run_check_shopping_product_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_shopping_product_performance"], *args, **kwargs)
def run_check_gender_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_gender_performance"], *args, **kwargs)
def run_check_age_performance(*args, **kwargs) -> None:
run_generic_audit(DEFINITIONS["check_age_performance"], *args, **kwargs)

View File

@@ -0,0 +1,544 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_auction_insights"
TASK_NAME = "Sprawdzenie Auction Insights"
SCOPE = [
{
"area": "Konkurenci w aukcji",
"check": "Pobierz domeny konkurentow z segmentu Auction Insights, jezeli developer token ma dostep do tych metryk.",
},
{
"area": "Overlap i outranking",
"check": "Pokaz overlap rate, position above rate i outranking share dla konkurentow.",
},
{
"area": "Widocznosc top",
"check": "Pokaz top rate i absolute top rate jako sygnal presji konkurencyjnej.",
},
{
"area": "Granica zadania",
"check": "Nie podejmuj tutaj decyzji o budzecie, stawkach ani strategiach; to tylko diagnoza aukcji.",
},
]
OUT_OF_SCOPE = [
"zmiany budzetow",
"zmiany stawek i strategii ustalania stawek",
"decyzje o zmianie Docelowego ROAS albo Docelowego CPA",
"analiza zapytan uzytkownikow",
"wdrazanie zmian na koncie Google Ads",
]
@dataclass
class AuctionInsightsPlan:
competitors: list[dict]
campaign_summary: list[dict]
domain_summary: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"competitors": self.competitors,
"campaign_summary": self.campaign_summary,
"domain_summary": self.domain_summary,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "AuctionInsightsPlan":
return cls(
competitors=data.get("competitors", []),
campaign_summary=data.get("campaign_summary", []),
domain_summary=data.get("domain_summary", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def percent(value: float | int | None) -> float:
if value is None:
return 0.0
return round(float(value or 0) * 100, 1)
def percent_label(value: float | int | None) -> str:
return f"{percent(value):.1f}%"
def compact_api_error(exc: Exception) -> str:
message = str(exc)
request_id = ""
if "request_id:" in message:
request_id = message.split("request_id:", 1)[1].splitlines()[0].strip().strip('"')
if "METRIC_ACCESS_DENIED" in message:
base = "METRIC_ACCESS_DENIED: developer token nie ma dostepu do metryk Auction Insights"
elif "PERMISSION_DENIED" in message:
base = "PERMISSION_DENIED: brak uprawnien do pobrania danych Auction Insights"
else:
base = "Nie udalo sie pobrac danych Auction Insights z Google Ads API"
if request_id:
return f"{base}. Request ID: {request_id}."
return f"{base}."
def auction_severity(row: dict) -> str:
if row["position_above_rate"] >= 0.5 or row["outranking_share"] <= 0.2:
return "wysokie"
if row["overlap_rate"] >= 0.5 or row["position_above_rate"] >= 0.3:
return "srednie"
return "niskie"
def auction_flags(row: dict) -> list[str]:
flags = []
if row["overlap_rate"] >= 0.5:
flags.append("wysoki overlap")
if row["position_above_rate"] >= 0.5:
flags.append("konkurent czesto wyzej")
elif row["position_above_rate"] >= 0.3:
flags.append("pozycja konkurenta do oceny")
if row["outranking_share"] <= 0.2:
flags.append("niski outranking share")
return flags or ["ok"]
def fetch_auction_insights(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
segments.auction_insight_domain,
metrics.auction_insight_search_impression_share,
metrics.auction_insight_search_overlap_rate,
metrics.auction_insight_search_position_above_rate,
metrics.auction_insight_search_outranking_share,
metrics.auction_insight_search_top_impression_percentage,
metrics.auction_insight_search_absolute_top_impression_percentage
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
LIMIT 1000
""",
)
competitors = []
for row in rows:
domain = str(row.segments.auction_insight_domain or "").strip()
if not domain:
continue
metrics = row.metrics
record = {
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"status": enum_name(row.campaign.status),
"channel_type": enum_name(row.campaign.advertising_channel_type),
"domain": domain,
"impression_share": float(metrics.auction_insight_search_impression_share or 0),
"overlap_rate": float(metrics.auction_insight_search_overlap_rate or 0),
"position_above_rate": float(metrics.auction_insight_search_position_above_rate or 0),
"outranking_share": float(metrics.auction_insight_search_outranking_share or 0),
"top_rate": float(metrics.auction_insight_search_top_impression_percentage or 0),
"absolute_top_rate": float(metrics.auction_insight_search_absolute_top_impression_percentage or 0),
}
record["severity"] = auction_severity(record)
record["flags"] = auction_flags(record)
competitors.append(record)
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
competitors.sort(
key=lambda row: (
severity_order.get(row["severity"], 9),
row["campaign_name"],
-row["overlap_rate"],
row["domain"],
)
)
return competitors
def build_campaign_summary(competitors: list[dict]) -> list[dict]:
buckets: dict[str, dict] = {}
for item in competitors:
row = buckets.setdefault(
item["campaign_name"],
{
"campaign_name": item["campaign_name"],
"channel_type": item["channel_type"],
"competitors": 0,
"high_risk": 0,
"max_overlap_rate": 0.0,
"max_position_above_rate": 0.0,
},
)
row["competitors"] += 1
if item["severity"] == "wysokie":
row["high_risk"] += 1
row["max_overlap_rate"] = max(row["max_overlap_rate"], item["overlap_rate"])
row["max_position_above_rate"] = max(row["max_position_above_rate"], item["position_above_rate"])
return sorted(buckets.values(), key=lambda row: (-row["high_risk"], -row["competitors"], row["campaign_name"]))
def build_domain_summary(competitors: list[dict]) -> list[dict]:
counter = Counter(item["domain"] for item in competitors)
return [{"domain": key, "campaigns": value} for key, value in counter.most_common()]
def build_auction_insights_plan(client_config: ClientConfig) -> AuctionInsightsPlan:
warnings = []
try:
competitors = fetch_auction_insights(client_config)
except Exception as exc:
competitors = []
message = str(exc)
if "METRIC_ACCESS_DENIED" in message or "auction_insight" in message:
warnings.append(
"Google Ads API zwrocilo brak dostepu do metryk Auction Insights dla obecnego developer tokena. "
"Zadanie jest gotowe, ale zacznie zwracac konkurentow dopiero po odblokowaniu tych metryk."
)
warnings.append(compact_api_error(exc))
if not competitors:
warnings.append("Nie znaleziono danych Auction Insights albo nie udalo sie ich pobrac.")
warnings.append(
"To zadanie tylko diagnozuje presje konkurencji w aukcji. Decyzje o budzetach, stawkach i strategiach pozostaja w osobnych zadaniach."
)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace Auction Insights bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return AuctionInsightsPlan(
competitors=competitors,
campaign_summary=build_campaign_summary(competitors),
domain_summary=build_domain_summary(competitors),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_auction_insights_plan(domain: str, plan: AuctionInsightsPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie Auction Insights",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Rekordy konkurentow: {len(plan.competitors)}",
f"- Kampanie z konkurentami: {len(plan.campaign_summary)}",
f"- Domeny konkurentow: {len(plan.domain_summary)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.campaign_summary:
lines.extend(
[
"## Podsumowanie po kampaniach",
"",
"| Kampania | Typ | Konkurenci | Wysokie ryzyko | Max overlap | Max position above |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for row in plan.campaign_summary:
lines.append(
f"| {md_cell(row['campaign_name'])} | {row['channel_type']} | {row['competitors']} | "
f"{row['high_risk']} | {percent_label(row['max_overlap_rate'])} | {percent_label(row['max_position_above_rate'])} |"
)
lines.append("")
if plan.domain_summary:
lines.extend(["## Domeny konkurentow", "", "| Domena | Kampanie |", "| --- | --- |"])
for row in plan.domain_summary:
lines.append(f"| {md_cell(row['domain'])} | {row['campaigns']} |")
lines.append("")
if plan.competitors:
lines.extend(
[
"## Auction Insights",
"",
"| Waznosc | Kampania | Konkurent | Impression share | Overlap | Position above | Outranking | Top | Abs. top | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.competitors:
lines.append(
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {md_cell(item['domain'])} | "
f"{percent_label(item['impression_share'])} | {percent_label(item['overlap_rate'])} | "
f"{percent_label(item['position_above_rate'])} | {percent_label(item['outranking_share'])} | "
f"{percent_label(item['top_rate'])} | {percent_label(item['absolute_top_rate'])} | "
f"{md_cell(', '.join(item['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_auction_insights_plan(plan: AuctionInsightsPlan) -> None:
print("\nPlan sprawdzenia Auction Insights")
print_table(
["Metryka", "Liczba"],
[
["Rekordy konkurentow", str(len(plan.competitors))],
["Kampanie z konkurentami", str(len(plan.campaign_summary))],
["Domeny konkurentow", str(len(plan.domain_summary))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.campaign_summary:
print("\nPodsumowanie po kampaniach")
print_table(
["Kampania", "Typ", "Konkurenci", "Wysokie", "Max overlap", "Max above"],
[
[
row["campaign_name"],
row["channel_type"],
str(row["competitors"]),
str(row["high_risk"]),
percent_label(row["max_overlap_rate"]),
percent_label(row["max_position_above_rate"]),
]
for row in plan.campaign_summary
],
)
if plan.domain_summary:
print("\nDomeny konkurentow")
print_table(["Domena", "Kampanie"], [[row["domain"], str(row["campaigns"])] for row in plan.domain_summary[:30]])
if plan.competitors:
print("\nAuction Insights")
print_table(
["Nr", "Waznosc", "Kampania", "Konkurent", "Overlap", "Above", "Outranking", "Flagi"],
[
[
str(index),
item["severity"],
item["campaign_name"],
item["domain"],
percent_label(item["overlap_rate"]),
percent_label(item["position_above_rate"]),
percent_label(item["outranking_share"]),
", ".join(item["flags"]),
]
for index, item in enumerate(plan.competitors[:30], 1)
],
)
if len(plan.competitors) > 30:
print(f"... oraz {len(plan.competitors) - 30} kolejnych rekordow w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_auction_insights_plan(
client_config: ClientConfig,
plan: AuctionInsightsPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem Auction Insights i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(row["campaign_name"] for row in plan.campaign_summary[:10]),
"summary": {
"competitors": len(plan.competitors),
"campaigns": len(plan.campaign_summary),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_auction_insights(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = AuctionInsightsPlan.from_dict(plan_data)
print_auction_insights_plan(plan)
apply_auction_insights_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia Auction Insights...")
plan = build_auction_insights_plan(client_config)
print_auction_insights_plan(plan)
json_path, md_path = save_auction_insights_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(row["campaign_name"] for row in plan.campaign_summary[:10]),
"summary": {
"competitors": len(plan.competitors),
"campaigns": len(plan.campaign_summary),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu Auction Insights.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,929 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from google.protobuf import field_mask_pb2
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_bidding_strategies"
TASK_NAME = "Sprawdzenie strategii stawek"
SCOPE = [
{
"area": "Typ strategii",
"check": "Pokaz typ strategii ustalania stawek dla aktywnych kampanii.",
},
{
"area": "Cele strategii",
"check": "Pokaz aktualny Docelowy ROAS albo Docelowy CPA, jezeli kampania go uzywa.",
},
{
"area": "Wolumen konwersji",
"check": "Sprawdz liczbe konwersji z ostatnich 30 dni jako kontekst dla automatycznych strategii.",
},
{
"area": "Stabilnosc decyzji",
"check": "Oznacz kampanie, gdzie malo danych zwieksza ryzyko pochopnej zmiany strategii albo celu.",
},
{
"area": "Ocena celu",
"check": "Porownaj rzeczywisty ROAS/CPA z aktualnym celem i oznacz cele zbyt niskie albo zbyt wysokie.",
},
{
"area": "Kontekst budzetu",
"check": "Uwzglednij wykorzystanie budzetu i utrate wyswietlania przez budzet przed rekomendacja zmiany strategii.",
},
{
"area": "Zmiany po budzecie",
"check": "Jesli budzet byl niedawno zmieniany, rekomenduj odczekanie przed zmiana strategii albo celu.",
},
{
"area": "Dopasowanie strategii",
"check": "Sprawdz, czy strategia pasuje do typu kampanii i dostepnego wolumenu danych.",
},
{
"area": "Rekomendacja",
"check": "Pokaz konkretna rekomendacje decyzyjna bez automatycznego wdrazania zmian strategii.",
},
]
OUT_OF_SCOPE = [
"budzety i pacing budzetu",
"podstawowe ustawienia kampanii, np. lokalizacje i sieci",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy, zasoby i kreacje",
"automatyczne wdrazanie zmian strategii stawek",
]
LOW_CONVERSION_THRESHOLD = 15
STABLE_CONVERSION_THRESHOLD = 30
TARGET_ROAS_TOO_LOW_RATIO = 1.5
TARGET_ROAS_TOO_HIGH_RATIO = 0.75
BUDGET_LOST_THRESHOLD = 0.15
HIGH_BUDGET_LOST_THRESHOLD = 0.3
RECENT_BUDGET_CHANGE_DAYS = 7
@dataclass
class BiddingStrategyPlan:
currency_code: str
campaigns: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
target_changes: list[dict] | None = None
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"campaigns": self.campaigns,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"target_changes": self.target_changes or [],
"changes": self.target_changes or [],
}
@classmethod
def from_dict(cls, data: dict) -> "BiddingStrategyPlan":
return cls(
currency_code=data.get("currency_code", ""),
campaigns=data.get("campaigns", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
target_changes=data.get("target_changes", data.get("changes", [])),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def format_decimal(value: int | float) -> str:
return f"{float(value or 0):.2f}"
def percent_label(value: int | float | None) -> str:
if value is None:
return "-"
return f"{float(value) * 100:.2f}%"
def safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def safe_float(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def target_roas_label(value: float) -> str:
if value <= 0:
return ""
return f"Docelowy ROAS {value * 100:.0f}%"
def target_cpa_label(value_micros: int, currency_code: str) -> str:
if value_micros <= 0:
return ""
return f"Docelowy CPA {format_money(value_micros, currency_code)}"
def strategy_target_label(row: dict, currency_code: str) -> str:
labels = [
target_roas_label(row.get("target_roas", 0)),
target_roas_label(row.get("maximize_conversion_value_target_roas", 0)),
target_cpa_label(row.get("target_cpa_micros", 0), currency_code),
target_cpa_label(row.get("maximize_conversions_target_cpa_micros", 0), currency_code),
]
return ", ".join(label for label in labels if label) or "brak jawnego celu"
def risk_label(strategy_type: str, conversions_30d: float) -> str:
strategy = strategy_type.upper()
automated = {
"MAXIMIZE_CONVERSIONS",
"MAXIMIZE_CONVERSION_VALUE",
"TARGET_CPA",
"TARGET_ROAS",
}
if conversions_30d <= 0:
return "brak konwersji w 30 dni"
if strategy in automated and conversions_30d < LOW_CONVERSION_THRESHOLD:
return "malo konwersji dla automatyzacji"
return "dane do oceny"
def actual_roas(cost_micros: int, conversion_value: float) -> float:
cost = micros_to_amount(cost_micros)
if cost <= 0:
return 0.0
return round(float(conversion_value or 0) / cost, 2)
def primary_target_roas(row: dict) -> float:
return safe_float(row.get("target_roas")) or safe_float(row.get("maximize_conversion_value_target_roas"))
def primary_target_cpa_micros(row: dict) -> int:
return safe_int(row.get("target_cpa_micros")) or safe_int(row.get("maximize_conversions_target_cpa_micros"))
def target_assessment(row: dict) -> str:
target_roas = primary_target_roas(row)
roas = safe_float(row.get("actual_roas"))
conversions = safe_float(row.get("conversions_30d"))
if target_roas > 0 and roas > 0:
if conversions < LOW_CONVERSION_THRESHOLD:
return "za malo danych do oceny celu ROAS"
if roas >= target_roas * TARGET_ROAS_TOO_LOW_RATIO:
return "Docelowy ROAS prawdopodobnie za niski"
if roas <= target_roas * TARGET_ROAS_TOO_HIGH_RATIO:
return "Docelowy ROAS prawdopodobnie za wysoki"
return "Docelowy ROAS zgodny z wynikiem"
if primary_target_cpa_micros(row) > 0:
if conversions < LOW_CONVERSION_THRESHOLD:
return "za malo danych do oceny celu CPA"
return "Docelowy CPA do oceny po koszcie na konwersje"
return "brak jawnego celu do oceny"
def stability_label(row: dict) -> str:
conversions = safe_float(row.get("conversions_30d"))
conversion_value = safe_float(row.get("conversion_value_30d"))
cost = safe_int(row.get("cost_30d_micros"))
if conversions <= 0:
return "niestabilne: brak konwersji"
if conversions < LOW_CONVERSION_THRESHOLD:
return "niestabilne: malo konwersji"
if conversions < STABLE_CONVERSION_THRESHOLD:
return "umiarkowane: dane do ostroznej decyzji"
if conversion_value <= 0 or cost <= 0:
return "niestabilne: brak wartosci albo kosztu"
return "stabilne"
def budget_context(row: dict, recent_budget_changes: set[str]) -> str:
if row["campaign_id"] in recent_budget_changes or row["campaign_name"] in recent_budget_changes:
return f"budzet zmieniony w ostatnich {RECENT_BUDGET_CHANGE_DAYS} dniach"
lost_budget = safe_float(row.get("search_budget_lost_impression_share"))
usage = safe_float(row.get("budget_usage_percent"))
if lost_budget >= HIGH_BUDGET_LOST_THRESHOLD:
return "mocne ograniczenie budzetem"
if lost_budget >= BUDGET_LOST_THRESHOLD:
return "ograniczenie budzetem do oceny"
if usage >= 95:
return "budzet blisko limitu"
if usage and usage < 30:
return "niskie wykorzystanie budzetu"
return "brak silnego sygnalu budzetowego"
def strategy_fit(row: dict) -> str:
strategy = row.get("bidding_strategy_type", "").upper()
channel = row.get("channel_type", "").upper()
conversions = safe_float(row.get("conversions_30d"))
if strategy == "TARGET_IMPRESSION_SHARE" and channel != "SEARCH":
return "do sprawdzenia: udzial w wyswietleniach poza Search"
if strategy == "TARGET_IMPRESSION_SHARE":
return "pasuje do kampanii brand/search, nie optymalizuje bezposrednio wartosci konwersji"
if strategy in {"TARGET_ROAS", "MAXIMIZE_CONVERSION_VALUE"}:
if conversions < LOW_CONVERSION_THRESHOLD:
return "ryzykowne: za malo konwersji dla strategii wartosci"
return "pasuje do kampanii e-commerce z wartoscia konwersji"
if strategy in {"TARGET_CPA", "MAXIMIZE_CONVERSIONS"}:
if conversions < LOW_CONVERSION_THRESHOLD:
return "ryzykowne: za malo konwersji dla automatyzacji"
return "pasuje do celu pozyskiwania konwersji"
return "do oceny recznej"
def bidding_recommendation(row: dict) -> dict:
if "budzet zmieniony" in row.get("budget_context", ""):
return {
"level": "czekaj",
"action": "odczekaj przed zmiana strategii",
"reason": "budzet byl niedawno zmieniony; najpierw zbierz nowe dane po zmianie",
}
if row.get("budget_context") in {"mocne ograniczenie budzetem", "ograniczenie budzetem do oceny"}:
return {
"level": "czekaj",
"action": "najpierw rozwiaz ograniczenie budzetem",
"reason": "zmiana celu strategii przy ograniczeniu budzetem moze zaciemnic efekt decyzji",
}
if row.get("stability_label", "").startswith("niestabilne"):
return {
"level": "ostroznie",
"action": "nie zmieniaj strategii",
"reason": row["stability_label"],
}
assessment = row.get("target_assessment", "")
if "za niski" in assessment:
return {
"level": "do decyzji",
"action": "rozwaz stopniowe podniesienie Docelowego ROAS",
"reason": "rzeczywisty ROAS jest wyraznie wyzszy niz cel i dane sa wystarczajace",
}
if "za wysoki" in assessment:
return {
"level": "do decyzji",
"action": "rozwaz obnizenie Docelowego ROAS albo analize rentownosci",
"reason": "rzeczywisty ROAS jest wyraznie ponizej celu",
}
return {
"level": "ok",
"action": "bez zmiany strategii",
"reason": "brak mocnego sygnalu do zmiany celu albo strategii",
}
def recent_budget_change_campaigns(domain: str) -> set[str]:
history_dir = client_dir(domain) / "history"
changed: set[str] = set()
if not history_dir.exists():
return changed
cutoff = now_local() - timedelta(days=RECENT_BUDGET_CHANGE_DAYS)
for path in history_dir.glob("*.jsonl"):
try:
for line in path.read_text(encoding="utf-8-sig").splitlines():
if not line.strip():
continue
row = json.loads(line)
timestamp = row.get("timestamp")
if timestamp:
try:
if datetime.fromisoformat(timestamp) < cutoff:
continue
except ValueError:
pass
summary = row.get("summary", {})
if not summary.get("budget_changes"):
continue
campaign_text = str(row.get("campaign", ""))
for name in campaign_text.split(","):
name = name.strip()
if name:
changed.add(name)
except Exception:
continue
return changed
def bidding_recommendations(campaigns: list[dict]) -> list[dict]:
return [
campaign
for campaign in campaigns
if campaign.get("bidding_recommendation", {}).get("level") not in {"ok", None}
]
def roas_target_field(row: dict) -> str:
strategy = row.get("bidding_strategy_type", "").upper()
if strategy == "TARGET_ROAS" and safe_float(row.get("target_roas")) > 0:
return "target_roas.target_roas"
if strategy == "MAXIMIZE_CONVERSION_VALUE" and safe_float(row.get("maximize_conversion_value_target_roas")) > 0:
return "maximize_conversion_value.target_roas"
return ""
def suggested_target_roas_change(row: dict) -> dict | None:
field = roas_target_field(row)
if not field:
return None
recommendation = row.get("bidding_recommendation", {})
if recommendation.get("level") != "do decyzji":
return None
current = primary_target_roas(row)
actual = safe_float(row.get("actual_roas"))
if current <= 0 or actual <= 0:
return None
assessment = row.get("target_assessment", "")
if "za niski" in assessment:
target = min(current * 1.10, actual * 0.80)
direction = "podniesienie"
elif "za wysoki" in assessment:
target = max(current * 0.90, actual * 1.20)
direction = "obnizenie"
else:
return None
target = round(target, 2)
if abs(target - current) < 0.01:
return None
return {
"campaign_id": row["campaign_id"],
"campaign_name": row["campaign_name"],
"strategy": row["bidding_strategy_type"],
"field": field,
"current_target_roas": round(current, 2),
"target_roas": target,
"current_label": f"{current * 100:.0f}%",
"target_label": f"{target * 100:.0f}%",
"actual_roas": actual,
"direction": direction,
"reason": recommendation.get("reason", ""),
}
def target_change_candidates(campaigns: list[dict]) -> list[dict]:
changes = []
for campaign in campaigns:
change = suggested_target_roas_change(campaign)
if change:
changes.append(change)
return changes
def fetch_bidding_targets(google_client, customer_id: str, warnings: list[str]) -> dict[str, dict]:
try:
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.target_cpa.target_cpa_micros,
campaign.target_roas.target_roas,
campaign.maximize_conversions.target_cpa_micros,
campaign.maximize_conversion_value.target_roas
FROM campaign
WHERE campaign.status = 'ENABLED'
""",
)
except Exception as exc:
warnings.append(f"Nie udalo sie pobrac celow Docelowy ROAS/CPA: {exc}")
return {}
targets: dict[str, dict] = {}
for row in rows:
campaign = row.campaign
targets[str(campaign.id)] = {
"target_cpa_micros": safe_int(campaign.target_cpa.target_cpa_micros),
"target_roas": safe_float(campaign.target_roas.target_roas),
"maximize_conversions_target_cpa_micros": safe_int(
campaign.maximize_conversions.target_cpa_micros
),
"maximize_conversion_value_target_roas": safe_float(
campaign.maximize_conversion_value.target_roas
),
}
return targets
def fetch_bidding_campaigns(client_config: ClientConfig) -> tuple[str, list[dict], list[str]]:
warnings: list[str] = []
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
targets_by_campaign = fetch_bidding_targets(google_client, customer_id, warnings)
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.bidding_strategy_type,
campaign.bidding_strategy,
campaign_budget.id,
campaign_budget.name,
campaign_budget.amount_micros,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.search_impression_share,
metrics.search_budget_lost_impression_share,
metrics.search_rank_lost_impression_share
FROM campaign
WHERE campaign.status = 'ENABLED'
AND segments.date DURING LAST_30_DAYS
""",
)
currency_code = ""
campaigns = []
for row in rows:
currency_code = currency_code or str(row.customer.currency_code or "")
campaign = row.campaign
campaign_id = str(campaign.id)
record = {
"campaign_id": campaign_id,
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"bidding_strategy_type": enum_name(campaign.bidding_strategy_type),
"bidding_strategy_resource": str(campaign.bidding_strategy or ""),
"budget_id": str(row.campaign_budget.id),
"budget_name": row.campaign_budget.name,
"daily_budget_micros": safe_int(row.campaign_budget.amount_micros),
"cost_30d_micros": safe_int(row.metrics.cost_micros),
"conversions_30d": safe_float(row.metrics.conversions),
"conversion_value_30d": safe_float(row.metrics.conversions_value),
"search_impression_share": safe_float(row.metrics.search_impression_share),
"search_budget_lost_impression_share": safe_float(row.metrics.search_budget_lost_impression_share),
"search_rank_lost_impression_share": safe_float(row.metrics.search_rank_lost_impression_share),
}
expected_30d_micros = record["daily_budget_micros"] * 30
record["budget_usage_percent"] = (
round((record["cost_30d_micros"] / expected_30d_micros) * 100, 1)
if expected_30d_micros
else 0.0
)
record["actual_roas"] = actual_roas(record["cost_30d_micros"], record["conversion_value_30d"])
record.update(targets_by_campaign.get(campaign_id, {}))
record["target_label"] = strategy_target_label(record, currency_code)
record["risk_label"] = risk_label(record["bidding_strategy_type"], record["conversions_30d"])
campaigns.append(record)
return currency_code, campaigns, warnings
def build_bidding_strategy_plan(client_config: ClientConfig) -> BiddingStrategyPlan:
warnings = []
try:
currency_code, campaigns, fetch_warnings = fetch_bidding_campaigns(client_config)
warnings.extend(fetch_warnings)
except Exception as exc:
currency_code = ""
campaigns = []
warnings.append(f"Nie udalo sie pobrac strategii stawek z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono aktywnych kampanii z danymi z ostatnich 30 dni albo nie udalo sie ich pobrac.")
recent_budget_changes = recent_budget_change_campaigns(client_config.domain)
for campaign in campaigns:
campaign["target_assessment"] = target_assessment(campaign)
campaign["stability_label"] = stability_label(campaign)
campaign["budget_context"] = budget_context(campaign, recent_budget_changes)
campaign["strategy_fit"] = strategy_fit(campaign)
campaign["bidding_recommendation"] = bidding_recommendation(campaign)
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace strategii stawek bedziemy dopisywac osobno po akceptacji uzytkownika."
)
recommendation_order = {"czekaj": 0, "do decyzji": 1, "ostroznie": 2, "ok": 9}
campaigns.sort(
key=lambda row: (
recommendation_order.get(row.get("bidding_recommendation", {}).get("level"), 9),
row["risk_label"],
row["campaign_name"],
)
)
target_changes = target_change_candidates(campaigns)
return BiddingStrategyPlan(
currency_code=currency_code,
campaigns=campaigns,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
target_changes=target_changes,
)
def save_bidding_strategy_plan(domain: str, plan: BiddingStrategyPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie strategii stawek",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie aktywne z danymi 30 dni: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
f"- Rekomendacje strategii do decyzji: {len(bidding_recommendations(plan.campaigns))}",
f"- Zmiany celu do wdrozenia po akceptacji: {len(plan.target_changes or [])}",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Strategie stawek z ostatnich 30 dni",
"",
"| Kampania | Typ | Strategia | Cel | Koszt | Konwersje | Wartosc konwersji | ROAS | Utrata budzet | Ocena celu | Stabilnosc | Budzet |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {campaign['campaign_name']} | {campaign['channel_type']} | "
f"{campaign['bidding_strategy_type']} | {campaign['target_label']} | "
f"{format_money(campaign['cost_30d_micros'], plan.currency_code)} | "
f"{format_decimal(campaign['conversions_30d'])} | "
f"{format_decimal(campaign['conversion_value_30d'])} | "
f"{format_decimal(campaign.get('actual_roas', 0))} | "
f"{percent_label(campaign.get('search_budget_lost_impression_share'))} | "
f"{campaign.get('target_assessment', '')} | "
f"{campaign.get('stability_label', '')} | "
f"{campaign.get('budget_context', '')} |"
)
lines.append("")
recommendations = bidding_recommendations(plan.campaigns)
if recommendations:
lines.extend(
[
"## Rekomendacje strategii do decyzji",
"",
"| Kampania | Waznosc | Rekomendacja | Powod | Dopasowanie strategii |",
"| --- | --- | --- | --- | --- |",
]
)
for campaign in recommendations:
recommendation = campaign["bidding_recommendation"]
lines.append(
f"| {campaign['campaign_name']} | {recommendation['level']} | "
f"{recommendation['action']} | {recommendation['reason']} | "
f"{campaign.get('strategy_fit', '')} |"
)
lines.append("")
if plan.target_changes:
lines.extend(
[
"## Zmiany celu strategii do akceptacji",
"",
"| Kampania | Strategia | Kierunek | Obecnie | Docelowo | Rzeczywisty ROAS | Powod |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
)
for change in plan.target_changes:
lines.append(
f"| {change['campaign_name']} | {change['strategy']} | {change['direction']} | "
f"{change['current_label']} | {change['target_label']} | "
f"{format_decimal(change['actual_roas'])} | {change['reason']} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_bidding_strategy_plan(plan: BiddingStrategyPlan) -> None:
print("\nPlan sprawdzenia strategii stawek")
print_table(
["Metryka", "Liczba"],
[
["Kampanie z danymi 30 dni", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Rekomendacje strategii", str(len(bidding_recommendations(plan.campaigns)))],
["Zmiany celu do wdrozenia", str(len(plan.target_changes or []))],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.campaigns:
print("\nStrategie stawek z ostatnich 30 dni")
print_table(
["Nr", "Kampania", "Typ", "Strategia", "Cel", "Konw.", "ROAS", "Utrata budz.", "Ocena celu", "Budzet"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
campaign["bidding_strategy_type"],
campaign["target_label"],
format_decimal(campaign["conversions_30d"]),
format_decimal(campaign.get("actual_roas", 0)),
percent_label(campaign.get("search_budget_lost_impression_share")),
campaign.get("target_assessment", ""),
campaign.get("budget_context", ""),
]
for index, campaign in enumerate(plan.campaigns, 1)
],
)
if plan.target_changes:
print("\nZmiany celu strategii do akceptacji")
print_table(
["Nr", "Kampania", "Strategia", "Kierunek", "Obecnie", "Docelowo", "ROAS", "Powod"],
[
[
str(index),
change["campaign_name"],
change["strategy"],
change["direction"],
change["current_label"],
change["target_label"],
format_decimal(change["actual_roas"]),
change["reason"],
]
for index, change in enumerate(plan.target_changes, 1)
],
)
recommendations = bidding_recommendations(plan.campaigns)
if recommendations:
print("\nRekomendacje strategii do decyzji")
print_table(
["Nr", "Kampania", "Waznosc", "Rekomendacja", "Powod", "Dopasowanie"],
[
[
str(index),
campaign["campaign_name"],
campaign["bidding_recommendation"]["level"],
campaign["bidding_recommendation"]["action"],
campaign["bidding_recommendation"]["reason"],
campaign.get("strategy_fit", ""),
]
for index, campaign in enumerate(recommendations, 1)
],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_bidding_strategy_plan(
client_config: ClientConfig,
plan: BiddingStrategyPlan,
show_navigation: bool = True,
) -> None:
target_changes = plan.target_changes or []
changed = 0
errors = []
if target_changes:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
service = google_client.get_service("CampaignService")
operations = []
for change in target_changes:
op = google_client.get_type("CampaignOperation")
campaign = op.update
campaign.resource_name = service.campaign_path(customer_id, change["campaign_id"])
field = change["field"]
if field == "target_roas.target_roas":
campaign.target_roas.target_roas = float(change["target_roas"])
elif field == "maximize_conversion_value.target_roas":
campaign.maximize_conversion_value.target_roas = float(change["target_roas"])
else:
errors.append(f"Nieobslugiwane pole celu: {field}")
continue
op.update_mask = field_mask_pb2.FieldMask(paths=[field])
operations.append(op)
if operations:
try:
response = service.mutate_campaigns(customer_id=customer_id, operations=operations)
changed = len(response.results)
except Exception as exc:
errors.append(str(exc))
if target_changes:
print("\nWynik wdrozenia zmian celu strategii")
print(f"Zmieniono kampanii: {changed}")
print(f"Bledy: {len(errors)}")
for error in errors:
print(f"Blad: {error}")
else:
print("\nTo zadanie jest audytem strategii stawek i nie ma zmian celu do wdrozenia.")
rows = [
{
"klient": client_config.domain,
"kampania": change.get("campaign_name", ""),
"czynnosc": f"Zmien Docelowy ROAS: {change.get('current_label', '')} -> {change.get('target_label', '')}",
"grupa reklam": "",
"produkt": change.get("reason", ""),
}
for change in target_changes
]
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "wdrozono zmiany celu strategii" if target_changes and not errors else "audyt oznaczony jako wykonany",
"campaign": ", ".join(change.get("campaign_name", "") for change in target_changes)
or ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"target_changes": len(target_changes),
"changed": changed,
"errors": len(errors),
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_bidding_strategies(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = BiddingStrategyPlan.from_dict(plan_data)
print_bidding_strategy_plan(plan)
apply_bidding_strategy_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia strategii stawek...")
plan = build_bidding_strategy_plan(client_config)
print_bidding_strategy_plan(plan)
json_path, md_path = save_bidding_strategy_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"target_changes": len(plan.target_changes or []),
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak automatycznego wdrozenia. Uzyj zapisanego planu i potwierdzenia, aby wdrozyc zmiany celu.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,804 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from google.protobuf import field_mask_pb2
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_budget_usage"
TASK_NAME = "Sprawdzenie wykorzystania budzetu"
SCOPE = [
{
"area": "Wydatki 7 dni",
"check": "Porownaj koszt z ostatnich 7 dni z oczekiwanym wydatkiem wynikajacym z budzetu dziennego.",
},
{
"area": "Pacing",
"check": "Oznacz kampanie, ktore wydaja bardzo malo albo prawie caly tygodniowy limit budzetu.",
},
{
"area": "Utrata wyswietlania przez budzet",
"check": "Polacz wykorzystanie budzetu z utrata udzialu w wyswietleniach przez budzet i rentownoscia kampanii.",
},
{
"area": "Brak wydatkow",
"check": "Wskaz aktywne kampanie z budzetem, ktore nie wydaly srodkow w ostatnich 7 dniach.",
},
{
"area": "Budzet wspoldzielony",
"check": "Pokaz nazwe budzetu, zeby latwiej wychwycic kampanie korzystajace z tego samego budzetu.",
},
]
OUT_OF_SCOPE = [
"zmiany stawek i strategii ustalania stawek",
"ocena Docelowego ROAS albo Docelowego CPA",
"analiza zapytan, wykluczen i jakosci ruchu",
"wdrazanie zmian budzetowych na koncie",
]
@dataclass
class BudgetUsagePlan:
currency_code: str
campaigns: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
budget_changes: list[dict] | None = None
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"campaigns": self.campaigns,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"budget_changes": self.budget_changes or [],
"changes": self.budget_changes or [],
}
@classmethod
def from_dict(cls, data: dict) -> "BudgetUsagePlan":
return cls(
currency_code=data.get("currency_code", ""),
campaigns=data.get("campaigns", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
budget_changes=data.get("budget_changes", data.get("changes", [])),
)
def enum_name(value) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def percent(value: int | float, total: int | float) -> float:
if not total:
return 0.0
return round((float(value) / float(total)) * 100, 1)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def percent_label(value: int | float | None) -> str:
if value is None:
return "-"
return f"{float(value) * 100:.2f}%"
def roas_label(value: int | float) -> str:
if not value:
return "-"
return f"{float(value):.2f}"
def days_since_label(value) -> str:
if value is None:
return "brak danych"
if value == 0:
return "dzis"
if value == 1:
return "1 dzien temu"
return f"{value} dni temu"
def budget_recommendations(campaigns: list[dict]) -> list[dict]:
return [
campaign
for campaign in campaigns
if campaign.get("budget_recommendation", {}).get("level") not in {"ok", None}
]
def pacing_label(cost_7d_micros: int, expected_7d_micros: int) -> str:
if expected_7d_micros <= 0:
return "brak budzetu"
usage = percent(cost_7d_micros, expected_7d_micros)
if cost_7d_micros <= 0:
return "brak wydatkow"
if usage < 30:
return "niskie wykorzystanie"
if usage > 95:
return "blisko limitu"
return "w normie"
def last_budget_change_date(domain: str, campaign_id: str, campaign_name: str) -> datetime | None:
"""Najnowsza data wdrozonej zmiany budzetu dla kampanii, z plikow historii."""
base = client_dir(domain) / "history"
if not base.exists():
return None
latest: datetime | None = None
for path in sorted(base.glob("*.jsonl")):
try:
content = path.read_text(encoding="utf-8-sig")
except OSError:
continue
for raw in content.splitlines():
raw = raw.strip()
if not raw:
continue
try:
event = json.loads(raw)
except json.JSONDecodeError:
continue
if event.get("status") != "wdrozono zmiany budzetu":
continue
changes = event.get("budget_changes") or []
if changes:
matched = any(str(item.get("campaign_id")) == str(campaign_id) for item in changes)
else:
# starsze wpisy bez szczegolow - dopasowanie po nazwie kampanii
matched = (
event.get("task") == TASK_NAME
and bool(campaign_name)
and campaign_name in (event.get("campaign") or "")
)
if not matched:
continue
try:
ts = datetime.fromisoformat(event["timestamp"])
except (KeyError, ValueError):
continue
if latest is None or ts > latest:
latest = ts
return latest
def build_budget_recommendation(campaign: dict, min_days_between_budget_changes: int = 0) -> dict:
usage = float(campaign.get("usage_percent") or 0)
lost_budget = float(campaign.get("search_budget_lost_impression_share") or 0)
cost_micros = int(campaign.get("cost_7d_micros") or 0)
daily_budget_micros = int(campaign.get("daily_budget_micros") or 0)
conversion_value = float(campaign.get("conversions_value") or 0)
roas = float(campaign.get("roas") or 0)
days_since = campaign.get("days_since_budget_change")
increase_percent = 0
if lost_budget >= 0.5:
increase_percent = 30
elif lost_budget >= 0.3:
increase_percent = 25
elif lost_budget >= 0.15:
increase_percent = 15
recommended_budget_micros = daily_budget_micros
if increase_percent:
recommended_budget_micros = int(round(daily_budget_micros * (1 + increase_percent / 100)))
budget_delta_micros = recommended_budget_micros - daily_budget_micros
if usage >= 90 and lost_budget >= 0.3 and conversion_value > 0 and roas >= 2:
recommendation = {
"level": "wysokie",
"action": "rozważ podniesienie budżetu",
"reason": "kampania prawie wykorzystuje budzet, traci duzo wyswietlen przez budzet i ma dodatnia rentownosc",
"suggested_budget_change_percent": increase_percent,
"current_daily_budget_micros": daily_budget_micros,
"recommended_daily_budget_micros": recommended_budget_micros,
"budget_delta_micros": budget_delta_micros,
}
elif usage >= 90 and lost_budget >= 0.15 and conversion_value > 0 and roas >= 1:
recommendation = {
"level": "srednie",
"action": "sprawdz mozliwosc podniesienia budżetu",
"reason": "kampania wykorzystuje budzet i traci czesc wyswietlen przez budzet",
"suggested_budget_change_percent": increase_percent,
"current_daily_budget_micros": daily_budget_micros,
"recommended_daily_budget_micros": recommended_budget_micros,
"budget_delta_micros": budget_delta_micros,
}
elif usage >= 90 and lost_budget >= 0.15:
recommendation = {
"level": "ostroznie",
"action": "nie podnoś budżetu bez oceny rentowności",
"reason": "widac utrate przez budzet, ale brakuje wystarczajacej wartosci konwersji",
"suggested_budget_change_percent": 0,
"current_daily_budget_micros": daily_budget_micros,
"recommended_daily_budget_micros": daily_budget_micros,
"budget_delta_micros": 0,
}
elif cost_micros <= 0:
recommendation = {
"level": "do sprawdzenia",
"action": "sprawdz brak wydatkow",
"reason": "aktywna kampania nie wydala srodkow w ostatnich 7 dniach",
"suggested_budget_change_percent": 0,
"current_daily_budget_micros": daily_budget_micros,
"recommended_daily_budget_micros": daily_budget_micros,
"budget_delta_micros": 0,
}
else:
recommendation = {
"level": "ok",
"action": "bez zmiany budzetu",
"reason": "brak jednoczesnego sygnalu wysokiego wykorzystania i utraty przez budzet",
"suggested_budget_change_percent": 0,
"current_daily_budget_micros": daily_budget_micros,
"recommended_daily_budget_micros": daily_budget_micros,
"budget_delta_micros": 0,
}
recommendation["days_since_budget_change"] = days_since
recommendation["min_days_between_budget_changes"] = min_days_between_budget_changes
# Wstrzymaj rekomendacje podniesienia budzetu, jesli budzet zmieniono zbyt niedawno.
if (
recommendation["level"] in {"wysokie", "srednie"}
and min_days_between_budget_changes > 0
and days_since is not None
and days_since < min_days_between_budget_changes
):
recommendation = {
"level": "wstrzymane",
"action": "nie zmieniaj budzetu jeszcze",
"reason": (
f"budzet zmieniony {days_since} dni temu, minimum {min_days_between_budget_changes}; "
f"pierwotna rekomendacja: {recommendation['level']}"
),
"suggested_budget_change_percent": 0,
"current_daily_budget_micros": daily_budget_micros,
"recommended_daily_budget_micros": daily_budget_micros,
"budget_delta_micros": 0,
"days_since_budget_change": days_since,
"min_days_between_budget_changes": min_days_between_budget_changes,
}
return recommendation
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def fetch_budget_campaigns(
client_config: ClientConfig,
min_days_between_budget_changes: int = 0,
) -> tuple[str, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.id,
campaign_budget.name,
campaign_budget.amount_micros,
campaign_budget.delivery_method,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.search_impression_share,
metrics.search_budget_lost_impression_share,
metrics.search_rank_lost_impression_share
FROM campaign
WHERE campaign.status = 'ENABLED'
AND segments.date DURING LAST_7_DAYS
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
budget = row.campaign_budget
daily_budget_micros = int(budget.amount_micros or 0)
metrics = row.metrics
cost_7d_micros = int(metrics.cost_micros or 0)
expected_7d_micros = daily_budget_micros * 7
conversions_value = float(metrics.conversions_value or 0)
cost = micros_to_amount(cost_7d_micros)
record = {
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"budget_id": str(budget.id),
"budget_name": budget.name,
"budget_delivery_method": enum_name(budget.delivery_method),
"daily_budget_micros": daily_budget_micros,
"expected_7d_micros": expected_7d_micros,
"cost_7d_micros": cost_7d_micros,
"avg_daily_cost_micros": int(cost_7d_micros / 7),
"usage_percent": percent(cost_7d_micros, expected_7d_micros),
"pacing_label": pacing_label(cost_7d_micros, expected_7d_micros),
"conversions": round(float(metrics.conversions or 0), 2),
"conversions_value": round(conversions_value, 2),
"roas": round(conversions_value / cost, 2) if cost else 0,
"search_impression_share": float(metrics.search_impression_share or 0),
"search_budget_lost_impression_share": float(metrics.search_budget_lost_impression_share or 0),
"search_rank_lost_impression_share": float(metrics.search_rank_lost_impression_share or 0),
}
last_change = last_budget_change_date(
client_config.domain, record["campaign_id"], record["campaign_name"]
)
record["days_since_budget_change"] = (
(now_local() - last_change).days if last_change else None
)
record["budget_recommendation"] = build_budget_recommendation(
record, min_days_between_budget_changes
)
campaigns.append(record)
return currency_code, campaigns
def build_budget_usage_plan(
client_config: ClientConfig,
global_rules: dict | None = None,
) -> BudgetUsagePlan:
warnings = []
budget_rules = client_config.effective_rules(global_rules or {}, "budget_usage")
min_days_between_budget_changes = int(
budget_rules.get("min_days_between_budget_changes", 0) or 0
)
try:
currency_code, campaigns = fetch_budget_campaigns(
client_config, min_days_between_budget_changes
)
except Exception as exc:
currency_code = ""
campaigns = []
warnings.append(f"Nie udalo sie pobrac budzetow z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono aktywnych kampanii z danymi kosztow z ostatnich 7 dni albo nie udalo sie ich pobrac.")
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly budzetowe bedziemy dopisywac osobno po akceptacji uzytkownika."
)
campaigns.sort(key=lambda row: (-row["usage_percent"], row["campaign_name"]))
return BudgetUsagePlan(
currency_code=currency_code,
campaigns=campaigns,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
budget_changes=[],
)
def save_budget_usage_plan(domain: str, plan: BudgetUsagePlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie wykorzystania budzetu",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie aktywne z danymi 7 dni: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
f"- Rekomendacje budzetowe do decyzji: {len(budget_recommendations(plan.campaigns))}",
f"- Zmiany budzetu do wdrozenia: {len(plan.budget_changes or [])}",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Wykorzystanie budzetu z ostatnich 7 dni",
"",
"| Kampania | Typ | Budzet dzienny | Koszt 7 dni | Uzycie 7 dni | Utrata przez budzet | ROAS | Status | Ost. zmiana budzetu | Budzet |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {campaign['campaign_name']} | {campaign['channel_type']} | "
f"{format_money(campaign['daily_budget_micros'], plan.currency_code)} | "
f"{format_money(campaign['cost_7d_micros'], plan.currency_code)} | "
f"{campaign['usage_percent']:.1f}% | "
f"{percent_label(campaign.get('search_budget_lost_impression_share'))} | "
f"{roas_label(campaign.get('roas', 0))} | "
f"{campaign['pacing_label']} | "
f"{days_since_label(campaign.get('days_since_budget_change'))} | "
f"{campaign['budget_name']} |"
)
lines.append("")
recommendations = budget_recommendations(plan.campaigns)
if recommendations:
lines.extend(
[
"## Rekomendacje budzetowe do decyzji",
"",
"| Kampania | Waznosc | Obecnie | Propozycja | Zmiana | Powod |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for campaign in recommendations:
recommendation = campaign["budget_recommendation"]
lines.append(
f"| {campaign['campaign_name']} | {recommendation['level']} | "
f"{format_money(recommendation['current_daily_budget_micros'], plan.currency_code)} | "
f"{format_money(recommendation['recommended_daily_budget_micros'], plan.currency_code)} | "
f"+{recommendation['suggested_budget_change_percent']}% "
f"({format_money(recommendation['budget_delta_micros'], plan.currency_code)}) | "
f"{recommendation['reason']} |"
)
lines.append("")
if plan.budget_changes:
lines.extend(
[
"## Zmiany budzetu do wdrozenia",
"",
"| Kampania | Budzet | Obecnie | Docelowo | Zmiana | Powod |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for change in plan.budget_changes:
lines.append(
f"| {change.get('campaign_name', '')} | {change.get('budget_name', '')} | "
f"{format_money(change.get('current_daily_budget_micros', 0), plan.currency_code)} | "
f"{format_money(change.get('target_daily_budget_micros', 0), plan.currency_code)} | "
f"{format_money(change.get('delta_micros', 0), plan.currency_code)} | "
f"{change.get('reason', '')} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_budget_usage_plan(plan: BudgetUsagePlan) -> None:
print("\nPlan sprawdzenia wykorzystania budzetu")
print_table(
["Metryka", "Liczba"],
[
["Kampanie z danymi 7 dni", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Rekomendacje budzetowe", str(len(budget_recommendations(plan.campaigns)))],
["Zmiany budzetu do wdrozenia", str(len(plan.budget_changes or []))],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.campaigns:
print("\nWykorzystanie budzetu z ostatnich 7 dni")
print_table(
["Nr", "Kampania", "Typ", "Budzet dzienny", "Koszt 7 dni", "Uzycie", "Utrata budz.", "ROAS", "Status", "Ost. zm. budz."],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
format_money(campaign["daily_budget_micros"], plan.currency_code),
format_money(campaign["cost_7d_micros"], plan.currency_code),
f"{campaign['usage_percent']:.1f}%",
percent_label(campaign.get("search_budget_lost_impression_share")),
roas_label(campaign.get("roas", 0)),
campaign["pacing_label"],
days_since_label(campaign.get("days_since_budget_change")),
]
for index, campaign in enumerate(plan.campaigns, 1)
],
)
if plan.budget_changes:
print("\nZmiany budzetu do wdrozenia")
print_table(
["Nr", "Kampania", "Budzet", "Obecnie", "Docelowo", "Zmiana", "Powod"],
[
[
str(index),
change.get("campaign_name", ""),
change.get("budget_name", ""),
format_money(change.get("current_daily_budget_micros", 0), plan.currency_code),
format_money(change.get("target_daily_budget_micros", 0), plan.currency_code),
format_money(change.get("delta_micros", 0), plan.currency_code),
change.get("reason", ""),
]
for index, change in enumerate(plan.budget_changes, 1)
],
)
recommendations = budget_recommendations(plan.campaigns)
if recommendations:
print("\nRekomendacje budzetowe do decyzji")
print_table(
["Nr", "Kampania", "Waznosc", "Obecnie", "Propozycja", "Zmiana", "Powod"],
[
[
str(index),
campaign["campaign_name"],
campaign["budget_recommendation"]["level"],
format_money(campaign["budget_recommendation"]["current_daily_budget_micros"], plan.currency_code),
format_money(
campaign["budget_recommendation"]["recommended_daily_budget_micros"],
plan.currency_code,
),
(
f"+{campaign['budget_recommendation']['suggested_budget_change_percent']}% "
f"({format_money(campaign['budget_recommendation']['budget_delta_micros'], plan.currency_code)})"
),
campaign["budget_recommendation"]["reason"],
]
for index, campaign in enumerate(recommendations, 1)
],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_budget_usage_plan(
client_config: ClientConfig,
plan: BudgetUsagePlan,
show_navigation: bool = True,
) -> None:
budget_changes = plan.budget_changes or []
changed = 0
errors = []
if budget_changes:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
service = google_client.get_service("CampaignBudgetService")
operations = []
for change in budget_changes:
op = google_client.get_type("CampaignBudgetOperation")
budget = op.update
budget.resource_name = service.campaign_budget_path(customer_id, change["budget_id"])
budget.amount_micros = int(change["target_daily_budget_micros"])
op.update_mask = field_mask_pb2.FieldMask(paths=["amount_micros"])
operations.append(op)
if operations:
try:
response = service.mutate_campaign_budgets(customer_id=customer_id, operations=operations)
changed = len(response.results)
except Exception as exc:
errors.append(str(exc))
if budget_changes:
print("\nWynik wdrozenia zmian budzetu")
print(f"Zmieniono budzetow: {changed}")
print(f"Bledy: {len(errors)}")
for error in errors:
print(f"Blad: {error}")
else:
print("\nTo zadanie jest audytem budzetow i nie ma zmian budzetu do wdrozenia.")
rows = [
{
"klient": client_config.domain,
"kampania": change.get("campaign_name", ""),
"czynnosc": "Zmien budzet dzienny",
"grupa reklam": "",
"produkt": (
f"{format_money(change.get('current_daily_budget_micros', 0), plan.currency_code)} -> "
f"{format_money(change.get('target_daily_budget_micros', 0), plan.currency_code)}"
),
}
for change in budget_changes
]
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "wdrozono zmiany budzetu" if budget_changes and not errors else "audyt oznaczony jako wykonany",
"campaign": ", ".join(
change.get("campaign_name", "") for change in budget_changes
)
or ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"budget_changes": [
{
"campaign_id": change.get("campaign_id", ""),
"campaign_name": change.get("campaign_name", ""),
"budget_id": change.get("budget_id", ""),
"target_daily_budget_micros": change.get("target_daily_budget_micros", 0),
}
for change in budget_changes
],
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"budget_changes": len(budget_changes),
"changed": changed,
"errors": len(errors),
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_budget_usage(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = BudgetUsagePlan.from_dict(plan_data)
print_budget_usage_plan(plan)
apply_budget_usage_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia wykorzystania budzetu...")
plan = build_budget_usage_plan(client_config, global_rules)
print_budget_usage_plan(plan)
json_path, md_path = save_budget_usage_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu budzetow.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,567 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_campaign_languages"
TASK_NAME = "Sprawdzenie jezykow kampanii"
SCOPE = [
{
"area": "Jezyki kampanii",
"check": "Wypisz jezyki przypisane do kampanii i oznacz kampanie bez jawnych kryteriow jezykowych.",
},
{
"area": "Rynek klienta",
"check": "Oznacz ustawienia wymagajace recznej oceny zgodnosci z rynkiem klienta.",
},
{
"area": "Typ kampanii",
"check": "Pokaz jezyki razem z typem kampanii, zeby osobno oceniac Search, Shopping i PMax.",
},
{
"area": "Audyt miesieczny",
"check": "Przygotuj szybki przeglad ustawien jezykowych, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
},
]
OUT_OF_SCOPE = [
"budzety i wykorzystanie budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy RSA, assety i kreacje",
"wdrazanie zmian jezykow na koncie Google Ads",
]
@dataclass
class CampaignLanguagePlan:
campaigns: list[dict]
language_summary: list[dict]
channel_summary: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"language_summary": self.language_summary,
"channel_summary": self.channel_summary,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "CampaignLanguagePlan":
return cls(
campaigns=data.get("campaigns", []),
language_summary=data.get("language_summary", []),
channel_summary=data.get("channel_summary", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def language_label(resource_name: str, language_constants: dict[str, dict]) -> str:
language = language_constants.get(resource_name)
if not language:
return resource_name
code = language.get("code", "")
name = language.get("name", "")
if code:
return f"{name} ({code})" if name else code
return name or resource_name
def campaign_flags(campaign: dict) -> list[str]:
flags = []
language_count = campaign["languages_count"]
labels = " ".join(language["label"].casefold() for language in campaign["languages"])
if language_count == 0:
flags.append("brak jawnych jezykow")
if language_count > 3:
flags.append("wiele jezykow do oceny")
if "polish" not in labels and "polski" not in labels and "pl" not in labels:
flags.append("brak oczywistego jezyka polskiego")
if campaign["channel_type"] in {"PERFORMANCE_MAX", "SHOPPING"}:
flags.append("sprawdz razem z feedem i rynkiem")
return flags or ["ok"]
def fetch_campaign_language_settings(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type
FROM campaign
WHERE campaign.status != 'REMOVED'
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
campaigns.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"languages": [],
}
)
return campaigns
def fetch_language_criteria(client_config: ClientConfig) -> tuple[dict[str, list[dict]], dict[str, dict], list[str]]:
google_client = get_google_ads_client(use_proto_plus=True)
warnings = []
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign_criterion.criterion_id,
campaign_criterion.status,
campaign_criterion.language.language_constant
FROM campaign_criterion
WHERE campaign.status != 'REMOVED'
AND campaign_criterion.status != 'REMOVED'
AND campaign_criterion.type = 'LANGUAGE'
""",
)
languages_by_campaign: dict[str, list[dict]] = {}
resource_names = set()
for row in rows:
campaign_id = str(row.campaign.id)
criterion = row.campaign_criterion
resource_name = str(criterion.language.language_constant or "")
if resource_name:
resource_names.add(resource_name)
languages_by_campaign.setdefault(campaign_id, []).append(
{
"criterion_id": str(criterion.criterion_id),
"resource_name": resource_name,
"status": enum_name(criterion.status),
}
)
language_constants = fetch_language_constant_names(
google_client,
client_config.safe_customer_id,
sorted(resource_names),
warnings,
)
return languages_by_campaign, language_constants, warnings
def fetch_language_constant_names(
google_client,
customer_id: str,
resource_names: list[str],
warnings: list[str],
) -> dict[str, dict]:
if not resource_names:
return {}
language_constants: dict[str, dict] = {}
chunk_size = 200
for start in range(0, len(resource_names), chunk_size):
chunk = resource_names[start : start + chunk_size]
quoted = ", ".join(f"'{name}'" for name in chunk)
try:
rows = run_query(
google_client,
customer_id,
f"""
SELECT
language_constant.resource_name,
language_constant.name,
language_constant.code,
language_constant.targetable
FROM language_constant
WHERE language_constant.resource_name IN ({quoted})
""",
)
except Exception as exc:
warnings.append(f"Nie udalo sie pobrac nazw language_constant: {exc}")
return language_constants
for row in rows:
language = row.language_constant
language_constants[str(language.resource_name)] = {
"resource_name": str(language.resource_name),
"name": str(language.name or ""),
"code": str(language.code or ""),
"targetable": bool(language.targetable),
}
return language_constants
def attach_languages(
campaigns: list[dict],
languages_by_campaign: dict[str, list[dict]],
language_constants: dict[str, dict],
) -> list[dict]:
for campaign in campaigns:
campaign_id = campaign["campaign_id"]
campaign["languages"] = [
{
**item,
"label": language_label(item["resource_name"], language_constants),
"code": language_constants.get(item["resource_name"], {}).get("code", ""),
"name": language_constants.get(item["resource_name"], {}).get("name", ""),
}
for item in languages_by_campaign.get(campaign_id, [])
]
campaign["languages_count"] = len(campaign["languages"])
campaign["flags"] = campaign_flags(campaign)
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
return campaigns
def build_language_summary(campaigns: list[dict]) -> list[dict]:
counter: Counter[str] = Counter()
for campaign in campaigns:
if not campaign["languages"]:
counter["(brak jawnych jezykow)"] += 1
continue
for language in campaign["languages"]:
counter[language.get("label") or language.get("resource_name") or "(brak nazwy)"] += 1
return [{"language": key, "campaigns": value} for key, value in counter.most_common()]
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
counter = Counter(row["channel_type"] for row in campaigns)
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
def join_language_labels(languages: list[dict], limit: int = 8) -> str:
if not languages:
return "(brak)"
labels = [item.get("label") or item.get("resource_name") or "" for item in languages]
shown = labels[:limit]
if len(labels) > limit:
shown.append(f"... +{len(labels) - limit}")
return ", ".join(shown)
def build_campaign_language_plan(client_config: ClientConfig) -> CampaignLanguagePlan:
warnings = []
try:
campaigns = fetch_campaign_language_settings(client_config)
languages_by_campaign, language_constants, language_warnings = fetch_language_criteria(client_config)
warnings.extend(language_warnings)
campaigns = attach_languages(campaigns, languages_by_campaign, language_constants)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac ustawien jezykow z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien jezykow.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace jezykow kampanii bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return CampaignLanguagePlan(
campaigns=campaigns,
language_summary=build_language_summary(campaigns),
channel_summary=build_channel_summary(campaigns),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_campaign_language_plan(domain: str, plan: CampaignLanguagePlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie jezykow kampanii",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.language_summary:
lines.extend(["## Podsumowanie jezykow", "", "| Jezyk | Kampanie |", "| --- | --- |"])
for row in plan.language_summary:
lines.append(f"| {md_cell(row['language'])} | {row['campaigns']} |")
lines.append("")
if plan.channel_summary:
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
for row in plan.channel_summary:
lines.append(f"| {row['channel_type']} | {row['count']} |")
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie",
"",
"| Kampania | Typ | Status | Jezyki | Flagi |",
"| --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
f"{md_cell(join_language_labels(campaign['languages']))} | "
f"{md_cell(', '.join(campaign['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_campaign_language_plan(plan: CampaignLanguagePlan) -> None:
print("\nPlan sprawdzenia jezykow kampanii")
print_table(
["Metryka", "Liczba"],
[
["Kampanie", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.language_summary:
print("\nPodsumowanie jezykow")
print_table(
["Jezyk", "Kampanie"],
[[row["language"], str(row["campaigns"])] for row in plan.language_summary],
)
if plan.channel_summary:
print("\nPodsumowanie po typach kampanii")
print_table(
["Typ", "Liczba"],
[[row["channel_type"], str(row["count"])] for row in plan.channel_summary],
)
if plan.campaigns:
print("\nKampanie")
print_table(
["Nr", "Kampania", "Typ", "Jezyki", "Flagi"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
join_language_labels(campaign["languages"]),
", ".join(campaign["flags"]),
]
for index, campaign in enumerate(plan.campaigns[:30], 1)
],
)
if len(plan.campaigns) > 30:
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_campaign_language_plan(
client_config: ClientConfig,
plan: CampaignLanguagePlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem jezykow kampanii i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_campaign_languages(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = CampaignLanguagePlan.from_dict(plan_data)
print_campaign_language_plan(plan)
apply_campaign_language_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia jezykow kampanii...")
plan = build_campaign_language_plan(client_config)
print_campaign_language_plan(plan)
json_path, md_path = save_campaign_language_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu jezykow kampanii.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,599 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_campaign_locations"
TASK_NAME = "Sprawdzenie lokalizacji kampanii"
SCOPE = [
{
"area": "Tryb kierowania",
"check": "Pokaz pozytywny i negatywny tryb kierowania lokalizacja dla kazdej kampanii.",
},
{
"area": "Lokalizacje docelowe",
"check": "Wypisz lokalizacje dodane do kampanii i oznacz kampanie bez jawnych lokalizacji.",
},
{
"area": "Wykluczone lokalizacje",
"check": "Wypisz lokalizacje wykluczone i oznacz kampanie, w ktorych brakuje wykluczen do recznej oceny.",
},
{
"area": "Sprawnosc miesieczna",
"check": "Przygotuj szybki przeglad ustawien, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
},
]
OUT_OF_SCOPE = [
"budzety i wykorzystanie budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"zapytania uzytkownikow i wykluczenia slow kluczowych",
"reklamy, assety i kreacje",
"wdrazanie zmian lokalizacji na koncie Google Ads",
]
@dataclass
class CampaignLocationPlan:
campaigns: list[dict]
geo_type_summary: list[dict]
location_summary: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"geo_type_summary": self.geo_type_summary,
"location_summary": self.location_summary,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "CampaignLocationPlan":
return cls(
campaigns=data.get("campaigns", []),
geo_type_summary=data.get("geo_type_summary", []),
location_summary=data.get("location_summary", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def human_geo(value: str) -> str:
return {
"PRESENCE": "Obecnosc",
"PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie",
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
"UNKNOWN": "Nieznane",
"UNSPECIFIED": "Nieokreslone",
}.get(value, value)
def location_label(resource_name: str, geo_targets: dict[str, dict]) -> str:
target = geo_targets.get(resource_name)
if not target:
return resource_name
parts = [target.get("name", "")]
country_code = target.get("country_code", "")
target_type = target.get("target_type", "")
suffix = ", ".join(part for part in [country_code, target_type] if part)
if suffix:
parts.append(f"({suffix})")
return " ".join(part for part in parts if part).strip() or resource_name
def campaign_flags(campaign: dict) -> list[str]:
flags = []
if campaign["positive_geo_target_type"] != "PRESENCE":
flags.append("kierowanie nie tylko na obecnosc")
if campaign["positive_locations_count"] == 0:
flags.append("brak jawnych lokalizacji")
if campaign["negative_locations_count"] == 0:
flags.append("brak wykluczen lokalizacji")
if campaign["negative_geo_target_type"] in {"UNKNOWN", "UNSPECIFIED", ""}:
flags.append("nieznany tryb wykluczen")
return flags or ["ok"]
def fetch_campaign_geo_settings(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.geo_target_type_setting.positive_geo_target_type,
campaign.geo_target_type_setting.negative_geo_target_type
FROM campaign
WHERE campaign.status != 'REMOVED'
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
negative_geo = enum_name(campaign.geo_target_type_setting.negative_geo_target_type)
campaigns.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"positive_geo_target_type": positive_geo,
"positive_geo_target_type_label": human_geo(positive_geo),
"negative_geo_target_type": negative_geo,
"negative_geo_target_type_label": human_geo(negative_geo),
"positive_locations": [],
"negative_locations": [],
}
)
return campaigns
def fetch_location_criteria(client_config: ClientConfig) -> tuple[dict[str, list[dict]], dict[str, list[dict]], dict[str, dict], list[str]]:
google_client = get_google_ads_client(use_proto_plus=True)
warnings = []
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign_criterion.criterion_id,
campaign_criterion.negative,
campaign_criterion.status,
campaign_criterion.location.geo_target_constant
FROM campaign_criterion
WHERE campaign.status != 'REMOVED'
AND campaign_criterion.status != 'REMOVED'
AND campaign_criterion.type = 'LOCATION'
""",
)
positive: dict[str, list[dict]] = {}
negative: dict[str, list[dict]] = {}
resource_names = set()
for row in rows:
campaign_id = str(row.campaign.id)
criterion = row.campaign_criterion
resource_name = str(criterion.location.geo_target_constant or "")
if resource_name:
resource_names.add(resource_name)
item = {
"criterion_id": str(criterion.criterion_id),
"resource_name": resource_name,
"status": enum_name(criterion.status),
"negative": bool(criterion.negative),
}
target = negative if criterion.negative else positive
target.setdefault(campaign_id, []).append(item)
geo_targets = fetch_geo_target_names(google_client, client_config.safe_customer_id, sorted(resource_names), warnings)
return positive, negative, geo_targets, warnings
def fetch_geo_target_names(google_client, customer_id: str, resource_names: list[str], warnings: list[str]) -> dict[str, dict]:
if not resource_names:
return {}
geo_targets: dict[str, dict] = {}
chunk_size = 200
for start in range(0, len(resource_names), chunk_size):
chunk = resource_names[start : start + chunk_size]
quoted = ", ".join(f"'{name}'" for name in chunk)
try:
rows = run_query(
google_client,
customer_id,
f"""
SELECT
geo_target_constant.resource_name,
geo_target_constant.name,
geo_target_constant.country_code,
geo_target_constant.target_type,
geo_target_constant.status
FROM geo_target_constant
WHERE geo_target_constant.resource_name IN ({quoted})
""",
)
except Exception as exc:
warnings.append(f"Nie udalo sie pobrac nazw lokalizacji geo_target_constant: {exc}")
return geo_targets
for row in rows:
target = row.geo_target_constant
geo_targets[str(target.resource_name)] = {
"resource_name": str(target.resource_name),
"name": str(target.name or ""),
"country_code": str(target.country_code or ""),
"target_type": str(target.target_type or ""),
"status": enum_name(target.status),
}
return geo_targets
def attach_locations(
campaigns: list[dict],
positive: dict[str, list[dict]],
negative: dict[str, list[dict]],
geo_targets: dict[str, dict],
) -> list[dict]:
for campaign in campaigns:
campaign_id = campaign["campaign_id"]
campaign["positive_locations"] = [
{
**item,
"label": location_label(item["resource_name"], geo_targets),
}
for item in positive.get(campaign_id, [])
]
campaign["negative_locations"] = [
{
**item,
"label": location_label(item["resource_name"], geo_targets),
}
for item in negative.get(campaign_id, [])
]
campaign["positive_locations_count"] = len(campaign["positive_locations"])
campaign["negative_locations_count"] = len(campaign["negative_locations"])
campaign["flags"] = campaign_flags(campaign)
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
return campaigns
def build_geo_type_summary(campaigns: list[dict]) -> list[dict]:
counter = Counter(campaign["positive_geo_target_type_label"] for campaign in campaigns)
return [{"positive_geo_target_type": key, "count": value} for key, value in counter.most_common()]
def build_location_summary(campaigns: list[dict]) -> list[dict]:
return [
{
"metric": "Kampanie",
"count": len(campaigns),
},
{
"metric": "Kampanie bez jawnych lokalizacji",
"count": sum(1 for campaign in campaigns if campaign["positive_locations_count"] == 0),
},
{
"metric": "Kampanie bez wykluczen lokalizacji",
"count": sum(1 for campaign in campaigns if campaign["negative_locations_count"] == 0),
},
{
"metric": "Kampanie z kierowaniem innym niz Obecnosc",
"count": sum(1 for campaign in campaigns if campaign["positive_geo_target_type"] != "PRESENCE"),
},
]
def build_campaign_location_plan(client_config: ClientConfig) -> CampaignLocationPlan:
warnings = []
try:
campaigns = fetch_campaign_geo_settings(client_config)
positive, negative, geo_targets, location_warnings = fetch_location_criteria(client_config)
warnings.extend(location_warnings)
campaigns = attach_locations(campaigns, positive, negative, geo_targets)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac ustawien lokalizacji z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien lokalizacji.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace lokalizacji bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return CampaignLocationPlan(
campaigns=campaigns,
geo_type_summary=build_geo_type_summary(campaigns),
location_summary=build_location_summary(campaigns),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def join_location_labels(locations: list[dict], limit: int = 6) -> str:
if not locations:
return "(brak)"
labels = [item.get("label") or item.get("resource_name") or "" for item in locations]
shown = labels[:limit]
if len(labels) > limit:
shown.append(f"... +{len(labels) - limit}")
return ", ".join(shown)
def save_campaign_location_plan(domain: str, plan: CampaignLocationPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie lokalizacji kampanii",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.location_summary:
lines.extend(["## Podsumowanie lokalizacji", "", "| Metryka | Liczba |", "| --- | --- |"])
for row in plan.location_summary:
lines.append(f"| {row['metric']} | {row['count']} |")
lines.append("")
if plan.geo_type_summary:
lines.extend(["## Tryby kierowania lokalizacja", "", "| Tryb | Liczba kampanii |", "| --- | --- |"])
for row in plan.geo_type_summary:
lines.append(f"| {row['positive_geo_target_type']} | {row['count']} |")
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie",
"",
"| Kampania | Typ | Status | Tryb lokalizacji | Tryb wykluczen | Lokalizacje | Wykluczenia | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
f"{campaign['positive_geo_target_type_label']} | {campaign['negative_geo_target_type_label']} | "
f"{md_cell(join_location_labels(campaign['positive_locations']))} | "
f"{md_cell(join_location_labels(campaign['negative_locations']))} | "
f"{md_cell(', '.join(campaign['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_campaign_location_plan(plan: CampaignLocationPlan) -> None:
print("\nPlan sprawdzenia lokalizacji kampanii")
print_table(
["Metryka", "Liczba"],
[
["Kampanie", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.location_summary:
print("\nPodsumowanie lokalizacji")
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.location_summary])
if plan.geo_type_summary:
print("\nTryby kierowania lokalizacja")
print_table(
["Tryb", "Kampanie"],
[[row["positive_geo_target_type"], str(row["count"])] for row in plan.geo_type_summary],
)
if plan.campaigns:
print("\nKampanie")
print_table(
["Nr", "Kampania", "Typ", "Tryb", "Lok.", "Wykl.", "Flagi"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
campaign["positive_geo_target_type_label"],
str(campaign["positive_locations_count"]),
str(campaign["negative_locations_count"]),
", ".join(campaign["flags"]),
]
for index, campaign in enumerate(plan.campaigns[:30], 1)
],
)
if len(plan.campaigns) > 30:
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_campaign_location_plan(
client_config: ClientConfig,
plan: CampaignLocationPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem lokalizacji i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_campaign_locations(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = CampaignLocationPlan.from_dict(plan_data)
print_campaign_location_plan(plan)
apply_campaign_location_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia lokalizacji kampanii...")
plan = build_campaign_location_plan(client_config)
print_campaign_location_plan(plan)
json_path, md_path = save_campaign_location_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu lokalizacji.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_campaign_networks"
TASK_NAME = "Sprawdzenie sieci kampanii"
SCOPE = [
{
"area": "Search",
"check": "Sprawdz, czy kampanie Search maja wlaczony Google Search i czy partnerzy wyszukiwania sa ustawieni swiadomie.",
},
{
"area": "Siec reklamowa",
"check": "Oznacz kampanie Search z wlaczona siecia reklamowa jako ustawienie wymagajace recznej oceny.",
},
{
"area": "Typ kampanii",
"check": "Pokaz ustawienia sieci razem z typem kampanii, zeby nie mieszac Search, Shopping i PMax.",
},
{
"area": "Audyt ustawien",
"check": "Przygotuj szybki przeglad ustawien sieci, ktory mozna wykonywac rzadziej niz budzety i anomalie.",
},
]
OUT_OF_SCOPE = [
"budzety i wykorzystanie budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy RSA, assety i kreacje",
"wdrazanie zmian ustawien sieci na koncie Google Ads",
]
@dataclass
class CampaignNetworkPlan:
campaigns: list[dict]
channel_summary: list[dict]
network_summary: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"channel_summary": self.channel_summary,
"network_summary": self.network_summary,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "CampaignNetworkPlan":
return cls(
campaigns=data.get("campaigns", []),
channel_summary=data.get("channel_summary", []),
network_summary=data.get("network_summary", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def yes_no(value: bool) -> str:
return "TAK" if value else "NIE"
def campaign_flags(campaign: dict) -> list[str]:
flags = []
if campaign["channel_type"] == "SEARCH":
if not campaign["target_google_search"]:
flags.append("Search bez Google Search")
if campaign["target_content_network"]:
flags.append("Search z wlaczona siecia reklamowa")
if campaign["target_partner_search_network"]:
flags.append("partnerzy wyszukiwania do oceny")
if campaign["channel_type"] == "SHOPPING" and campaign["target_content_network"]:
flags.append("Shopping z siecia reklamowa do oceny")
if campaign["channel_type"] in {"PERFORMANCE_MAX", "DISPLAY", "VIDEO", "DEMAND_GEN"}:
flags.append("sieci sterowane przez typ kampanii")
return flags or ["ok"]
def fetch_campaign_networks(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.network_settings.target_google_search,
campaign.network_settings.target_search_network,
campaign.network_settings.target_partner_search_network,
campaign.network_settings.target_content_network
FROM campaign
WHERE campaign.status != 'REMOVED'
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
record = {
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"target_google_search": bool(campaign.network_settings.target_google_search),
"target_search_network": bool(campaign.network_settings.target_search_network),
"target_partner_search_network": bool(campaign.network_settings.target_partner_search_network),
"target_content_network": bool(campaign.network_settings.target_content_network),
}
record["flags"] = campaign_flags(record)
campaigns.append(record)
campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"]))
return campaigns
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
counter = Counter(row["channel_type"] for row in campaigns)
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
def build_network_summary(campaigns: list[dict]) -> list[dict]:
return [
{
"metric": "Kampanie",
"count": len(campaigns),
},
{
"metric": "Google Search wlaczony",
"count": sum(1 for campaign in campaigns if campaign["target_google_search"]),
},
{
"metric": "Search Network wlaczony",
"count": sum(1 for campaign in campaigns if campaign["target_search_network"]),
},
{
"metric": "Partnerzy wyszukiwania wlaczeni",
"count": sum(1 for campaign in campaigns if campaign["target_partner_search_network"]),
},
{
"metric": "Siec reklamowa wlaczona",
"count": sum(1 for campaign in campaigns if campaign["target_content_network"]),
},
{
"metric": "Kampanie z flagami do oceny",
"count": sum(1 for campaign in campaigns if campaign["flags"] != ["ok"]),
},
]
def build_campaign_network_plan(client_config: ClientConfig) -> CampaignNetworkPlan:
warnings = []
try:
campaigns = fetch_campaign_networks(client_config)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac ustawien sieci z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien sieci.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace sieci kampanii bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return CampaignNetworkPlan(
campaigns=campaigns,
channel_summary=build_channel_summary(campaigns),
network_summary=build_network_summary(campaigns),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_campaign_network_plan(domain: str, plan: CampaignNetworkPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie sieci kampanii",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.network_summary:
lines.extend(["## Podsumowanie sieci", "", "| Metryka | Liczba |", "| --- | --- |"])
for row in plan.network_summary:
lines.append(f"| {row['metric']} | {row['count']} |")
lines.append("")
if plan.channel_summary:
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
for row in plan.channel_summary:
lines.append(f"| {row['channel_type']} | {row['count']} |")
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie",
"",
"| Kampania | Typ | Status | Google Search | Search Network | Partnerzy | Siec reklamowa | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | "
f"{yes_no(campaign['target_google_search'])} | "
f"{yes_no(campaign['target_search_network'])} | "
f"{yes_no(campaign['target_partner_search_network'])} | "
f"{yes_no(campaign['target_content_network'])} | "
f"{md_cell(', '.join(campaign['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_campaign_network_plan(plan: CampaignNetworkPlan) -> None:
print("\nPlan sprawdzenia sieci kampanii")
print_table(
["Metryka", "Liczba"],
[
["Kampanie", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.network_summary:
print("\nPodsumowanie sieci")
print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.network_summary])
if plan.channel_summary:
print("\nPodsumowanie po typach kampanii")
print_table(
["Typ", "Liczba"],
[[row["channel_type"], str(row["count"])] for row in plan.channel_summary],
)
if plan.campaigns:
print("\nKampanie")
print_table(
["Nr", "Kampania", "Typ", "Google", "Search net.", "Partnerzy", "Display", "Flagi"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
yes_no(campaign["target_google_search"]),
yes_no(campaign["target_search_network"]),
yes_no(campaign["target_partner_search_network"]),
yes_no(campaign["target_content_network"]),
", ".join(campaign["flags"]),
]
for index, campaign in enumerate(plan.campaigns[:30], 1)
],
)
if len(plan.campaigns) > 30:
print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_campaign_network_plan(
client_config: ClientConfig,
plan: CampaignNetworkPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem sieci kampanii i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_campaign_networks(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = CampaignNetworkPlan.from_dict(plan_data)
print_campaign_network_plan(plan)
apply_campaign_network_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia sieci kampanii...")
plan = build_campaign_network_plan(client_config)
print_campaign_network_plan(plan)
json_path, md_path = save_campaign_network_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu sieci kampanii.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from ..config import ClientConfig, client_dir
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_conversion_tracking"
TASK_NAME = "Sprawdzenie pomiaru konwersji"
DEFAULT_SCOPE = [
{
"area": "Konwersje Google Ads",
"check": "Sprawdz, czy glowne konwersje sa aktywne i oznaczone jako cele uzywane do optymalizacji.",
},
{
"area": "Duplikacja konwersji",
"check": "Sprawdz, czy konto nie liczy tych samych zdarzen jednoczesnie z Google Ads, GA4 i importow.",
},
{
"area": "E-commerce",
"check": "Sprawdz, czy konwersje zakupowe przekazuja wartosc i walute.",
},
{
"area": "Remarketing dynamiczny",
"check": "Sprawdz, czy tagowanie e-commerce przekazuje identyfikatory produktow.",
},
{
"area": "Enhanced Conversions",
"check": "Sprawdz, czy rozszerzone konwersje sa skonfigurowane tam, gdzie ma to sens.",
},
]
@dataclass
class ConversionTrackingPlan:
scope: list[dict]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"scope": self.scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "ConversionTrackingPlan":
return cls(
scope=data.get("scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def build_conversion_tracking_plan(client_config: ClientConfig) -> ConversionTrackingPlan:
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
warnings = []
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Uruchom `python gads.py wiedza przypisz --restart` i przypisz pasujace reguly do check_conversion_tracking."
)
if not client_config.google_ads_customer_id:
warnings.append("Klient nie ma google_ads_customer_id w config/clients.toml.")
return ConversionTrackingPlan(scope=DEFAULT_SCOPE, knowledge_rules=knowledge_rules, warnings=warnings)
def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie pomiaru konwersji",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Obszary audytu: {len(plan.scope)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres audytu", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_conversion_tracking_plan(plan: ConversionTrackingPlan) -> None:
print("\nPlan sprawdzenia pomiaru konwersji")
print_table(
["Metryka", "Liczba"],
[
["Obszary audytu", str(len(plan.scope))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres audytu")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_conversion_tracking_plan(
client_config: ClientConfig,
plan: ConversionTrackingPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": "",
"summary": {
"scope_items": len(plan.scope),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_conversion_tracking(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = ConversionTrackingPlan.from_dict(plan_data)
print_conversion_tracking_plan(plan)
apply_conversion_tracking_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia pomiaru konwersji...")
plan = build_conversion_tracking_plan(client_config)
print_conversion_tracking_plan(plan)
json_path, md_path = save_conversion_tracking_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": "",
"summary": {
"scope_items": len(plan.scope),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,667 @@
from __future__ import annotations
import json
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_device_performance"
TASK_NAME = "Sprawdzenie urzadzen"
SCOPE = [
{
"area": "Segment urzadzenia",
"check": "Porownaj wyniki kampanii z ostatnich 30 dni dla komputerow, telefonow i tabletow.",
},
{
"area": "Rentownosc",
"check": "Policz koszt, konwersje, wartosc konwersji, ROAS i CPA dla kazdego urzadzenia.",
},
{
"area": "Udzial kosztu",
"check": "Pokaz, jaka czesc kosztu kampanii przypada na dane urzadzenie.",
},
{
"area": "Sygnaly do oceny",
"check": "Oznacz urzadzenia z istotnym kosztem i slabymi wynikami albo wyraznie odmienna efektywnoscia.",
},
]
OUT_OF_SCOPE = [
"zmiany korekt stawek dla urzadzen",
"zmiany strategii ustalania stawek",
"zmiany budzetow kampanii",
"analiza zapytan uzytkownikow",
"wdrazanie zmian na koncie Google Ads",
]
@dataclass
class DevicePerformancePlan:
currency_code: str
device_summary: list[dict]
campaign_device_rows: list[dict]
findings: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"device_summary": self.device_summary,
"campaign_device_rows": self.campaign_device_rows,
"findings": self.findings,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "DevicePerformancePlan":
return cls(
currency_code=data.get("currency_code", ""),
device_summary=data.get("device_summary", []),
campaign_device_rows=data.get("campaign_device_rows", []),
findings=data.get("findings", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money_micros(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def format_money_amount(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{float(value or 0):.2f}{suffix}"
def format_number(value: int | float, decimals: int = 2) -> str:
return f"{float(value or 0):.{decimals}f}"
def percent(numerator: int | float, denominator: int | float) -> float:
if not denominator:
return 0.0
return round((float(numerator) / float(denominator)) * 100, 1)
def roas(conversion_value: float, cost_micros: int) -> float:
cost = micros_to_amount(cost_micros)
if not cost:
return 0.0
return round(float(conversion_value or 0) / cost, 2)
def cpa(cost_micros: int, conversions: float) -> float:
if not conversions:
return 0.0
return round(micros_to_amount(cost_micros) / float(conversions), 2)
def empty_metrics() -> dict:
return {
"cost_micros": 0,
"clicks": 0,
"impressions": 0,
"conversions": 0.0,
"conversion_value": 0.0,
}
def add_metrics(target: dict, metrics: Any) -> None:
target["cost_micros"] += int(metrics.cost_micros or 0)
target["clicks"] += int(metrics.clicks or 0)
target["impressions"] += int(metrics.impressions or 0)
target["conversions"] += float(metrics.conversions or 0)
target["conversion_value"] += float(metrics.conversions_value or 0)
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def fetch_device_rows(client_config: ClientConfig) -> tuple[str, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
segments.device,
metrics.cost_micros,
metrics.clicks,
metrics.impressions,
metrics.conversions,
metrics.conversions_value
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
""",
)
campaigns: dict[str, dict] = {}
for row in rows:
campaign_id = str(row.campaign.id)
campaign = campaigns.setdefault(
campaign_id,
{
"campaign_id": campaign_id,
"campaign_name": row.campaign.name,
"status": enum_name(row.campaign.status),
"channel_type": enum_name(row.campaign.advertising_channel_type),
"total": empty_metrics(),
"devices": defaultdict(empty_metrics),
},
)
add_metrics(campaign["total"], row.metrics)
add_metrics(campaign["devices"][enum_name(row.segments.device)], row.metrics)
result = []
for campaign in campaigns.values():
total_cost = campaign["total"]["cost_micros"]
for device, metrics in campaign["devices"].items():
record = {
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"status": campaign["status"],
"channel_type": campaign["channel_type"],
"device": device,
"cost_micros": metrics["cost_micros"],
"clicks": metrics["clicks"],
"impressions": metrics["impressions"],
"conversions": round(metrics["conversions"], 2),
"conversion_value": round(metrics["conversion_value"], 2),
"roas": roas(metrics["conversion_value"], metrics["cost_micros"]),
"cpa": cpa(metrics["cost_micros"], metrics["conversions"]),
"cost_share_percent": percent(metrics["cost_micros"], total_cost),
}
record["flags"] = device_flags(record)
record["severity"] = device_severity(record)
result.append(record)
result.sort(
key=lambda row: (
{"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}.get(row["severity"], 9),
row["campaign_name"],
-row["cost_micros"],
row["device"],
)
)
return currency_code, result
def device_flags(row: dict) -> list[str]:
flags = []
if row["cost_micros"] > 0 and row["conversions"] == 0 and row["cost_share_percent"] >= 20:
flags.append("koszt bez konwersji")
if row["cost_share_percent"] >= 50 and row["roas"] == 0:
flags.append("duzy udzial kosztu bez wartosci")
if row["clicks"] >= 30 and row["conversions"] == 0:
flags.append("wiele klikniec bez konwersji")
if row["roas"] > 0 and row["roas"] < 1 and row["cost_share_percent"] >= 20:
flags.append("niski ROAS przy istotnym koszcie")
if row["impressions"] == 0:
flags.append("brak wyswietlen")
return flags or ["ok"]
def device_severity(row: dict) -> str:
flags = set(row["flags"])
if "duzy udzial kosztu bez wartosci" in flags or "wiele klikniec bez konwersji" in flags:
return "wysokie"
if "koszt bez konwersji" in flags or "niski ROAS przy istotnym koszcie" in flags:
return "srednie"
if flags == {"ok"}:
return "ok"
return "niskie"
def build_device_summary(rows: list[dict]) -> list[dict]:
buckets: dict[str, dict] = defaultdict(empty_metrics)
total_cost = 0
for row in rows:
bucket = buckets[row["device"]]
bucket["cost_micros"] += row["cost_micros"]
bucket["clicks"] += row["clicks"]
bucket["impressions"] += row["impressions"]
bucket["conversions"] += row["conversions"]
bucket["conversion_value"] += row["conversion_value"]
total_cost += row["cost_micros"]
summary = []
for device, metrics in buckets.items():
summary.append(
{
"device": device,
"cost_micros": metrics["cost_micros"],
"clicks": metrics["clicks"],
"impressions": metrics["impressions"],
"conversions": round(metrics["conversions"], 2),
"conversion_value": round(metrics["conversion_value"], 2),
"roas": roas(metrics["conversion_value"], metrics["cost_micros"]),
"cpa": cpa(metrics["cost_micros"], metrics["conversions"]),
"cost_share_percent": percent(metrics["cost_micros"], total_cost),
}
)
summary.sort(key=lambda row: -row["cost_micros"])
return summary
def build_findings(rows: list[dict]) -> list[dict]:
findings = []
for row in rows:
if row["flags"] == ["ok"]:
continue
findings.append(
{
"severity": row["severity"],
"campaign_name": row["campaign_name"],
"channel_type": row["channel_type"],
"device": row["device"],
"cost_share_percent": row["cost_share_percent"],
"cost_micros": row["cost_micros"],
"conversions": row["conversions"],
"conversion_value": row["conversion_value"],
"roas": row["roas"],
"cpa": row["cpa"],
"flags": row["flags"],
"recommendation": "sprawdz urzadzenie w kontekscie kampanii przed decyzja o korektach stawek albo strukturze",
}
)
return findings
def build_device_performance_plan(client_config: ClientConfig) -> DevicePerformancePlan:
warnings = []
try:
currency_code, campaign_device_rows = fetch_device_rows(client_config)
except Exception as exc:
currency_code = ""
campaign_device_rows = []
warnings.append(f"Nie udalo sie pobrac segmentu urzadzen z Google Ads API: {exc}")
if not campaign_device_rows:
warnings.append("Nie znaleziono danych wedlug urzadzen z ostatnich 30 dni albo nie udalo sie ich pobrac.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace segmentow urzadzen bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return DevicePerformancePlan(
currency_code=currency_code,
device_summary=build_device_summary(campaign_device_rows),
campaign_device_rows=campaign_device_rows,
findings=build_findings(campaign_device_rows),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_device_performance_plan(domain: str, plan: DevicePerformancePlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie urzadzen",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Segmenty urzadzen w kampaniach: {len(plan.campaign_device_rows)}",
f"- Elementy do oceny: {len(plan.findings)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.device_summary:
lines.extend(
[
"## Podsumowanie po urzadzeniach",
"",
"| Urzadzenie | Koszt | Udzial kosztu | Klikniecia | Konwersje | Wartosc konwersji | ROAS | CPA |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for row in plan.device_summary:
lines.append(
f"| {row['device']} | {format_money_micros(row['cost_micros'], plan.currency_code)} | "
f"{row['cost_share_percent']:.1f}% | {row['clicks']} | {format_number(row['conversions'])} | "
f"{format_money_amount(row['conversion_value'], plan.currency_code)} | {format_number(row['roas'])} | "
f"{format_money_amount(row['cpa'], plan.currency_code)} |"
)
lines.append("")
if plan.findings:
lines.extend(
[
"## Elementy do oceny",
"",
"| Waznosc | Kampania | Typ | Urzadzenie | Koszt | Udzial kosztu | Konwersje | ROAS | Flagi | Rekomendacja |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.findings:
lines.append(
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['channel_type']} | {item['device']} | "
f"{format_money_micros(item['cost_micros'], plan.currency_code)} | {item['cost_share_percent']:.1f}% | "
f"{format_number(item['conversions'])} | {format_number(item['roas'])} | "
f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
)
lines.append("")
if plan.campaign_device_rows:
lines.extend(
[
"## Kampanie i urzadzenia",
"",
"| Kampania | Typ | Urzadzenie | Koszt | Udzial kosztu | Klikniecia | Konwersje | Wartosc konwersji | ROAS | CPA | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for row in plan.campaign_device_rows:
lines.append(
f"| {md_cell(row['campaign_name'])} | {row['channel_type']} | {row['device']} | "
f"{format_money_micros(row['cost_micros'], plan.currency_code)} | {row['cost_share_percent']:.1f}% | "
f"{row['clicks']} | {format_number(row['conversions'])} | "
f"{format_money_amount(row['conversion_value'], plan.currency_code)} | {format_number(row['roas'])} | "
f"{format_money_amount(row['cpa'], plan.currency_code)} | {md_cell(', '.join(row['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_device_performance_plan(plan: DevicePerformancePlan) -> None:
print("\nPlan sprawdzenia urzadzen")
print_table(
["Metryka", "Liczba"],
[
["Segmenty urzadzen w kampaniach", str(len(plan.campaign_device_rows))],
["Elementy do oceny", str(len(plan.findings))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.device_summary:
print("\nPodsumowanie po urzadzeniach")
print_table(
["Urzadzenie", "Koszt", "Udzial", "Klikniecia", "Konw.", "Wartosc", "ROAS", "CPA"],
[
[
row["device"],
format_money_micros(row["cost_micros"], plan.currency_code),
f"{row['cost_share_percent']:.1f}%",
str(row["clicks"]),
format_number(row["conversions"]),
format_money_amount(row["conversion_value"], plan.currency_code),
format_number(row["roas"]),
format_money_amount(row["cpa"], plan.currency_code),
]
for row in plan.device_summary
],
)
if plan.findings:
print("\nElementy do oceny")
print_table(
["Nr", "Waznosc", "Kampania", "Urzadzenie", "Koszt", "Udzial", "Konw.", "ROAS", "Flagi"],
[
[
str(index),
item["severity"],
item["campaign_name"],
item["device"],
format_money_micros(item["cost_micros"], plan.currency_code),
f"{item['cost_share_percent']:.1f}%",
format_number(item["conversions"]),
format_number(item["roas"]),
", ".join(item["flags"]),
]
for index, item in enumerate(plan.findings[:30], 1)
],
)
if len(plan.findings) > 30:
print(f"... oraz {len(plan.findings) - 30} kolejnych elementow w pliku planu")
if plan.campaign_device_rows:
print("\nKampanie i urzadzenia")
print_table(
["Nr", "Kampania", "Typ", "Urzadzenie", "Koszt", "Udzial", "Konw.", "ROAS", "Flagi"],
[
[
str(index),
row["campaign_name"],
row["channel_type"],
row["device"],
format_money_micros(row["cost_micros"], plan.currency_code),
f"{row['cost_share_percent']:.1f}%",
format_number(row["conversions"]),
format_number(row["roas"]),
", ".join(row["flags"]),
]
for index, row in enumerate(plan.campaign_device_rows[:30], 1)
],
)
if len(plan.campaign_device_rows) > 30:
print(f"... oraz {len(plan.campaign_device_rows) - 30} kolejnych wierszy w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_device_performance_plan(
client_config: ClientConfig,
plan: DevicePerformancePlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem urzadzen i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(item["campaign_name"] for item in plan.findings[:10]),
"summary": {
"device_rows": len(plan.campaign_device_rows),
"findings": len(plan.findings),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_device_performance(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = DevicePerformancePlan.from_dict(plan_data)
print_device_performance_plan(plan)
apply_device_performance_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia urzadzen...")
plan = build_device_performance_plan(client_config)
print_device_performance_plan(plan)
json_path, md_path = save_device_performance_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(item["campaign_name"] for item in plan.findings[:10]),
"summary": {
"device_rows": len(plan.campaign_device_rows),
"findings": len(plan.findings),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu urzadzen.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,446 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
from .product_feed_optimization import (
fetch_missing_category_products,
fetch_missing_title_products,
fetch_missing_unit_pricing_products,
)
TASK_ID = "check_feed_merchant_quality"
TASK_NAME = "Sprawdzenie feedu i Merchant Center"
DEFAULT_LIMIT = 50
SCOPE = [
{
"area": "Atrybuty feedu",
"check": "Sprawdz braki w danych produktowych widoczne w adsPRO, bez przygotowywania zmian produktow.",
},
{
"area": "Ryzyka Merchant Center",
"check": "Wypisz kontrole, ktore wymagaja pozniejszej integracji Merchant Center API.",
},
{
"area": "Routing problemow",
"check": "Rozdziel problemy feedu od zadan optymalizacji tytulow, kategorii Google i unit pricing.",
},
{
"area": "Przygotowanie do Shopping/PMax",
"check": "Zapisz plan audytu, ktory pozniej moze byc uzyty przez zadania Shopping i PMax.",
},
]
MERCHANT_CENTER_CHECKS = [
"swiezosc ostatniego przetworzenia feedu",
"liczba produktow active, warning i disapproved",
"procent aktywnych produktow",
"produkty odrzucone wedlug kodow problemow",
"landing page errors",
"broken images",
"agregacja problemow Merchant Center po issue code",
]
OUT_OF_SCOPE = [
"optymalizacja tytulow produktow",
"wybor kategorii Google",
"uzupelnianie unit pricing",
"synchronizacja grup reklam PLA_CL1",
"wdrazanie zmian w adsPRO albo Merchant Center",
]
@dataclass
class FeedMerchantQualityPlan:
attribute_checks: list[dict]
merchant_center_checks: list[str]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"attribute_checks": self.attribute_checks,
"merchant_center_checks": self.merchant_center_checks,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "FeedMerchantQualityPlan":
return cls(
attribute_checks=data.get("attribute_checks", []),
merchant_center_checks=data.get("merchant_center_checks", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def product_label(product: dict[str, Any]) -> str:
return str(
product.get("title")
or product.get("default_name")
or product.get("custom_title")
or product.get("name")
or ""
).strip()
def sample_products(products: list[dict], max_items: int = 8) -> list[dict]:
sample = []
for product in products[:max_items]:
sample.append(
{
"offer_id": str(product.get("offer_id") or product.get("id") or "").strip(),
"title": product_label(product),
"brand": str(product.get("brand") or "").strip(),
"google_product_category": str(
product.get("google_product_category") or product.get("google_category") or ""
).strip(),
"custom_label_1": str(product.get("custom_label_1") or "").strip(),
}
)
return sample
def attribute_check(name: str, issue: str, products: list[dict], target_task: str, limit: int) -> dict:
return {
"name": name,
"issue": issue,
"count": len(products),
"limit": limit,
"is_limited": len(products) >= limit,
"target_task": target_task,
"sample": sample_products(products),
}
def fetch_attribute_checks(client_config: ClientConfig, limit: int) -> list[dict]:
title_products = fetch_missing_title_products(client_config, limit)
category_products = fetch_missing_category_products(client_config, limit)
unit_pricing_products = fetch_missing_unit_pricing_products(client_config, limit)
return [
attribute_check(
"Tytuly produktow",
"Produkty bez zoptymalizowanego tytulu",
title_products,
"optimize_product_titles",
limit,
),
attribute_check(
"Kategorie Google",
"Produkty bez kategorii Google",
category_products,
"optimize_product_categories",
limit,
),
attribute_check(
"Unit pricing",
"Produkty bez unit pricing",
unit_pricing_products,
"fill_product_unit_pricing",
limit,
),
]
def build_feed_merchant_quality_plan(
client_config: ClientConfig,
global_rules: dict,
) -> FeedMerchantQualityPlan:
rules = client_config.effective_rules(global_rules, "feed_merchant_quality")
limit = int(rules.get("limit", DEFAULT_LIMIT))
warnings = []
try:
attribute_checks = fetch_attribute_checks(client_config, limit)
except Exception as exc:
attribute_checks = []
warnings.append(f"Nie udalo sie pobrac danych feedu z adsPRO: {exc}")
warnings.append(
"Statusy active/warning/disapproved, issue codes, landing page errors i broken images wymagaja pozniejszej integracji Merchant Center API."
)
warnings.append(
"To zadanie tylko wskazuje problemy feedu. Naprawy tytulow, kategorii Google i unit pricing pozostaja w osobnych zadaniach."
)
warnings.append(
f"Kontrole atrybutow z adsPRO sa pobierane do limitu {limit}; wartosc z plusem oznacza, ze problemow moze byc wiecej."
)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace feedu i Merchant Center bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return FeedMerchantQualityPlan(
attribute_checks=attribute_checks,
merchant_center_checks=MERCHANT_CENTER_CHECKS,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def count_label(check: dict) -> str:
value = str(check.get("count", 0))
if check.get("is_limited"):
return f"{value}+"
return value
def save_feed_merchant_quality_plan(domain: str, plan: FeedMerchantQualityPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie feedu i Merchant Center",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kontrole atrybutow z adsPRO: {len(plan.attribute_checks)}",
f"- Kontrole Merchant Center do pozniejszej integracji: {len(plan.merchant_center_checks)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.attribute_checks:
lines.extend(
[
"## Kontrole atrybutow z adsPRO",
"",
"| Obszar | Problem | Liczba | Zadanie naprawcze |",
"| --- | --- | --- | --- |",
]
)
for check in plan.attribute_checks:
lines.append(
f"| {md_cell(check['name'])} | {md_cell(check['issue'])} | "
f"{count_label(check)} | {md_cell(check['target_task'])} |"
)
lines.append("")
lines.extend(["## Kontrole Merchant Center do integracji", ""])
lines.extend(f"- {item}" for item in plan.merchant_center_checks)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_feed_merchant_quality_plan(plan: FeedMerchantQualityPlan) -> None:
print("\nPlan sprawdzenia feedu i Merchant Center")
print_table(
["Metryka", "Liczba"],
[
["Kontrole atrybutow", str(len(plan.attribute_checks))],
["Kontrole Merchant Center", str(len(plan.merchant_center_checks))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.attribute_checks:
print("\nKontrole atrybutow z adsPRO")
print_table(
["Nr", "Obszar", "Problem", "Liczba", "Zadanie naprawcze"],
[
[str(index), check["name"], check["issue"], count_label(check), check["target_task"]]
for index, check in enumerate(plan.attribute_checks, 1)
],
)
print("\nKontrole Merchant Center do pozniejszej integracji")
print_table(
["Nr", "Kontrola"],
[[str(index), item] for index, item in enumerate(plan.merchant_center_checks, 1)],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_feed_merchant_quality_plan(
client_config: ClientConfig,
plan: FeedMerchantQualityPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem feedu i nie wdraza zmian w adsPRO ani Merchant Center.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": "",
"summary": {
"attribute_checks": len(plan.attribute_checks),
"merchant_center_checks": len(plan.merchant_center_checks),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_feed_merchant_quality(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = FeedMerchantQualityPlan.from_dict(plan_data)
print_feed_merchant_quality_plan(plan)
apply_feed_merchant_quality_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia feedu i Merchant Center...")
plan = build_feed_merchant_quality_plan(client_config, global_rules)
print_feed_merchant_quality_plan(plan)
json_path, md_path = save_feed_merchant_quality_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": "",
"summary": {
"attribute_checks": len(plan.attribute_checks),
"merchant_center_checks": len(plan.merchant_center_checks),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu feedu.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,567 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_impression_share"
TASK_NAME = "Sprawdzenie udzialu w wyswietleniach"
SCOPE = [
{
"area": "Udzial w wyswietleniach",
"check": "Sprawdz search impression share dla kampanii z ostatnich 30 dni.",
},
{
"area": "Utrata przez budzet",
"check": "Oznacz kampanie z istotna utrata udzialu w wyswietleniach przez budzet.",
},
{
"area": "Utrata przez ranking",
"check": "Oznacz kampanie z istotna utrata udzialu w wyswietleniach przez ranking.",
},
{
"area": "Widocznosc top",
"check": "Pokaz top impression share i absolute top impression share jako sygnal konkurencyjnosci.",
},
]
OUT_OF_SCOPE = [
"zmiany budzetow",
"zmiany stawek i strategii ustalania stawek",
"decyzje o zmianie Docelowego ROAS albo Docelowego CPA",
"analiza zapytan uzytkownikow",
"wdrazanie zmian na koncie Google Ads",
]
@dataclass
class ImpressionSharePlan:
campaigns: list[dict]
channel_summary: list[dict]
problem_items: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"channel_summary": self.channel_summary,
"problem_items": self.problem_items,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "ImpressionSharePlan":
return cls(
campaigns=data.get("campaigns", []),
channel_summary=data.get("channel_summary", []),
problem_items=data.get("problem_items", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def percent(value: float | int | None) -> float:
if value is None:
return 0.0
return round(float(value or 0) * 100, 1)
def percent_label(value: float | int | None) -> str:
return f"{percent(value):.1f}%"
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def issue_severity(row: dict) -> str:
if row["search_budget_lost_impression_share"] >= 0.3:
return "wysokie"
if row["search_rank_lost_impression_share"] >= 0.5:
return "wysokie"
if row["search_budget_lost_impression_share"] >= 0.15:
return "srednie"
if row["search_rank_lost_impression_share"] >= 0.3:
return "srednie"
if row["search_impression_share"] and row["search_impression_share"] < 0.2:
return "niskie"
return "ok"
def campaign_flags(row: dict) -> list[str]:
flags = []
if row["search_budget_lost_impression_share"] >= 0.3:
flags.append("duza utrata przez budzet")
elif row["search_budget_lost_impression_share"] >= 0.15:
flags.append("utrata przez budzet do oceny")
if row["search_rank_lost_impression_share"] >= 0.5:
flags.append("duza utrata przez ranking")
elif row["search_rank_lost_impression_share"] >= 0.3:
flags.append("utrata przez ranking do oceny")
if row["search_impression_share"] and row["search_impression_share"] < 0.2:
flags.append("niski udzial w wyswietleniach")
if row["impressions"] == 0:
flags.append("brak wyswietlen 30 dni")
return flags or ["ok"]
def fetch_impression_share_campaigns(client_config: ClientConfig) -> tuple[str, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.search_impression_share,
metrics.search_budget_lost_impression_share,
metrics.search_rank_lost_impression_share,
metrics.search_top_impression_share,
metrics.search_absolute_top_impression_share
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
""",
)
campaigns = []
for row in rows:
metrics = row.metrics
record = {
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"status": enum_name(row.campaign.status),
"channel_type": enum_name(row.campaign.advertising_channel_type),
"impressions": int(metrics.impressions or 0),
"clicks": int(metrics.clicks or 0),
"cost_micros": int(metrics.cost_micros or 0),
"cost": format_money(metrics.cost_micros, currency_code),
"conversions": round(float(metrics.conversions or 0), 2),
"conversion_value": round(float(metrics.conversions_value or 0), 2),
"search_impression_share": float(metrics.search_impression_share or 0),
"search_budget_lost_impression_share": float(metrics.search_budget_lost_impression_share or 0),
"search_rank_lost_impression_share": float(metrics.search_rank_lost_impression_share or 0),
"search_top_impression_share": float(metrics.search_top_impression_share or 0),
"search_absolute_top_impression_share": float(metrics.search_absolute_top_impression_share or 0),
}
record["severity"] = issue_severity(record)
record["flags"] = campaign_flags(record)
campaigns.append(record)
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
campaigns.sort(
key=lambda row: (
severity_order.get(row["severity"], 9),
-row["search_budget_lost_impression_share"],
-row["search_rank_lost_impression_share"],
row["campaign_name"],
)
)
return currency_code, campaigns
def build_channel_summary(campaigns: list[dict]) -> list[dict]:
counter = Counter(row["channel_type"] for row in campaigns)
return [{"channel_type": key, "count": value} for key, value in counter.most_common()]
def build_problem_items(campaigns: list[dict]) -> list[dict]:
items = []
for campaign in campaigns:
if campaign["flags"] == ["ok"]:
continue
recommendation = "sprawdz osobno budzet, ranking, strategie stawek i jakosc ruchu przed decyzja o zmianach"
items.append(
{
"severity": campaign["severity"],
"campaign_name": campaign["campaign_name"],
"channel_type": campaign["channel_type"],
"impression_share": percent_label(campaign["search_impression_share"]),
"lost_budget": percent_label(campaign["search_budget_lost_impression_share"]),
"lost_rank": percent_label(campaign["search_rank_lost_impression_share"]),
"top_share": percent_label(campaign["search_top_impression_share"]),
"flags": campaign["flags"],
"recommendation": recommendation,
}
)
return items
def build_impression_share_plan(client_config: ClientConfig) -> ImpressionSharePlan:
warnings = []
try:
_currency_code, campaigns = fetch_impression_share_campaigns(client_config)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac udzialu w wyswietleniach z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii z danymi udzialu w wyswietleniach albo nie udalo sie ich pobrac.")
warnings.append(
"To zadanie tylko wskazuje utrate udzialu w wyswietleniach. Decyzje o budzetach i stawkach pozostaja w osobnych zadaniach."
)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace aukcji i udzialu w wyswietleniach bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return ImpressionSharePlan(
campaigns=campaigns,
channel_summary=build_channel_summary(campaigns),
problem_items=build_problem_items(campaigns),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_impression_share_plan(domain: str, plan: ImpressionSharePlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie udzialu w wyswietleniach",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie: {len(plan.campaigns)}",
f"- Elementy do oceny: {len(plan.problem_items)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.channel_summary:
lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"])
for row in plan.channel_summary:
lines.append(f"| {row['channel_type']} | {row['count']} |")
lines.append("")
if plan.problem_items:
lines.extend(
[
"## Elementy do oceny",
"",
"| Waznosc | Kampania | Typ | Udzial | Utrata budzet | Utrata ranking | Top | Flagi | Rekomendacja |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.problem_items:
lines.append(
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['channel_type']} | "
f"{item['impression_share']} | {item['lost_budget']} | {item['lost_rank']} | {item['top_share']} | "
f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie",
"",
"| Kampania | Typ | Wyswietlenia | Koszt | Udzial | Utrata budzet | Utrata ranking | Top | Abs. top | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['impressions']} | "
f"{campaign['cost']} | {percent_label(campaign['search_impression_share'])} | "
f"{percent_label(campaign['search_budget_lost_impression_share'])} | "
f"{percent_label(campaign['search_rank_lost_impression_share'])} | "
f"{percent_label(campaign['search_top_impression_share'])} | "
f"{percent_label(campaign['search_absolute_top_impression_share'])} | "
f"{md_cell(', '.join(campaign['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_impression_share_plan(plan: ImpressionSharePlan) -> None:
print("\nPlan sprawdzenia udzialu w wyswietleniach")
print_table(
["Metryka", "Liczba"],
[
["Kampanie", str(len(plan.campaigns))],
["Elementy do oceny", str(len(plan.problem_items))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.channel_summary:
print("\nPodsumowanie po typach kampanii")
print_table(["Typ", "Liczba"], [[row["channel_type"], str(row["count"])] for row in plan.channel_summary])
if plan.problem_items:
print("\nElementy do oceny")
print_table(
["Nr", "Waznosc", "Kampania", "Typ", "Udzial", "Utrata budzet", "Utrata ranking", "Flagi"],
[
[
str(index),
item["severity"],
item["campaign_name"],
item["channel_type"],
item["impression_share"],
item["lost_budget"],
item["lost_rank"],
", ".join(item["flags"]),
]
for index, item in enumerate(plan.problem_items[:30], 1)
],
)
if len(plan.problem_items) > 30:
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
if plan.campaigns:
print("\nKampanie")
print_table(
["Nr", "Kampania", "Typ", "Wysw.", "Udzial", "Budzet lost", "Rank lost", "Top", "Flagi"],
[
[
str(index),
campaign["campaign_name"],
campaign["channel_type"],
str(campaign["impressions"]),
percent_label(campaign["search_impression_share"]),
percent_label(campaign["search_budget_lost_impression_share"]),
percent_label(campaign["search_rank_lost_impression_share"]),
percent_label(campaign["search_top_impression_share"]),
", ".join(campaign["flags"]),
]
for index, campaign in enumerate(plan.campaigns[:30], 1)
],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_impression_share_plan(
client_config: ClientConfig,
plan: ImpressionSharePlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem aukcji i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_impression_share(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = ImpressionSharePlan.from_dict(plan_data)
print_impression_share_plan(plan)
apply_impression_share_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia udzialu w wyswietleniach...")
plan = build_impression_share_plan(client_config)
print_impression_share_plan(plan)
json_path, md_path = save_impression_share_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu udzialu w wyswietleniach.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,576 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_keyword_statuses"
TASK_NAME = "Sprawdzenie statusow slow kluczowych"
SCOPE = [
{
"area": "Status slow kluczowych",
"check": "Pokaz status kampanii, grupy reklam i slowa kluczowego dla aktywnych kampanii Search.",
},
{
"area": "Polityki",
"check": "Oznacz slowa z problemami polityk, jezeli Google Ads API udostepnia status zatwierdzenia.",
},
{
"area": "Jakosc techniczna",
"check": "Pokaz quality score i oznacz bardzo niskie wyniki jako techniczny sygnal do sprawdzenia.",
},
{
"area": "Oddzielenie od zapytan",
"check": "Nie analizuj tutaj search terms, wykluczen ani decyzji o dodawaniu nowych fraz.",
},
]
OUT_OF_SCOPE = [
"analiza zapytan uzytkownikow",
"dodawanie, usuwanie albo wykluczanie slow kluczowych",
"budzety i wykorzystanie budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"wdrazanie zmian slow kluczowych na koncie Google Ads",
]
PROBLEM_APPROVAL_STATUSES = {
"DISAPPROVED",
"APPROVED_LIMITED",
"AREA_OF_INTEREST_ONLY",
"UNDER_REVIEW",
"UNKNOWN",
"UNSPECIFIED",
}
PROBLEM_STATUS_FIELDS = {
"PAUSED",
"REMOVED",
"UNKNOWN",
"UNSPECIFIED",
}
@dataclass
class KeywordStatusPlan:
keywords: list[dict]
status_summary: list[dict]
approval_summary: list[dict]
quality_summary: list[dict]
problem_items: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"keywords": self.keywords,
"status_summary": self.status_summary,
"approval_summary": self.approval_summary,
"quality_summary": self.quality_summary,
"problem_items": self.problem_items,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "KeywordStatusPlan":
return cls(
keywords=data.get("keywords", []),
status_summary=data.get("status_summary", []),
approval_summary=data.get("approval_summary", []),
quality_summary=data.get("quality_summary", []),
problem_items=data.get("problem_items", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def safe_quality_score(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def quality_bucket(score: int) -> str:
if score <= 0:
return "brak danych"
if score <= 3:
return "niski"
if score <= 6:
return "sredni"
return "dobry"
def keyword_severity(row: dict) -> str:
if row["approval_status"] == "DISAPPROVED":
return "wysokie"
if row["keyword_status"] in PROBLEM_STATUS_FIELDS or row["ad_group_status"] in PROBLEM_STATUS_FIELDS:
return "srednie"
if row["approval_status"] in {"APPROVED_LIMITED", "AREA_OF_INTEREST_ONLY"}:
return "srednie"
if row["quality_score"] and row["quality_score"] <= 3:
return "srednie"
if row["approval_status"] in {"UNDER_REVIEW", "UNKNOWN", "UNSPECIFIED"}:
return "niskie"
return "ok"
def keyword_flags(row: dict) -> list[str]:
flags = []
if row["campaign_status"] in PROBLEM_STATUS_FIELDS:
flags.append(f"kampania: {row['campaign_status']}")
if row["ad_group_status"] in PROBLEM_STATUS_FIELDS:
flags.append(f"grupa reklam: {row['ad_group_status']}")
if row["keyword_status"] in PROBLEM_STATUS_FIELDS:
flags.append(f"slowo: {row['keyword_status']}")
if row["approval_status"] in PROBLEM_APPROVAL_STATUSES:
flags.append(f"polityka: {row['approval_status']}")
if row["quality_score"] and row["quality_score"] <= 3:
flags.append(f"niski quality score: {row['quality_score']}")
return flags or ["ok"]
def fetch_keywords(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
ad_group.id,
ad_group.name,
ad_group.status,
ad_group_criterion.criterion_id,
ad_group_criterion.status,
ad_group_criterion.keyword.text,
ad_group_criterion.keyword.match_type,
ad_group_criterion.quality_info.quality_score
FROM keyword_view
WHERE campaign.status != 'REMOVED'
AND ad_group.status != 'REMOVED'
AND ad_group_criterion.status != 'REMOVED'
AND ad_group_criterion.type = 'KEYWORD'
""",
)
keywords = []
for row in rows:
criterion = row.ad_group_criterion
keyword = criterion.keyword
quality_score = safe_quality_score(criterion.quality_info.quality_score)
record = {
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"campaign_status": enum_name(row.campaign.status),
"channel_type": enum_name(row.campaign.advertising_channel_type),
"ad_group_id": str(row.ad_group.id),
"ad_group_name": row.ad_group.name,
"ad_group_status": enum_name(row.ad_group.status),
"criterion_id": str(criterion.criterion_id),
"keyword_text": keyword.text,
"match_type": enum_name(keyword.match_type),
"keyword_status": enum_name(criterion.status),
"approval_status": "niedostepne w API",
"quality_score": quality_score,
"quality_bucket": quality_bucket(quality_score),
}
record["severity"] = keyword_severity(record)
record["flags"] = keyword_flags(record)
keywords.append(record)
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
keywords.sort(
key=lambda row: (
severity_order.get(row["severity"], 9),
row["campaign_name"],
row["ad_group_name"],
row["keyword_text"],
)
)
return keywords
def build_counter_summary(rows: list[dict], field: str, label: str) -> list[dict]:
counter = Counter(row.get(field, "") or "(brak)" for row in rows)
return [{label: key, "count": value} for key, value in counter.most_common()]
def build_problem_items(keywords: list[dict]) -> list[dict]:
problem_items = []
for keyword in keywords:
if keyword["flags"] == ["ok"]:
continue
problem_items.append(
{
"severity": keyword["severity"],
"campaign_name": keyword["campaign_name"],
"ad_group_name": keyword["ad_group_name"],
"keyword_text": keyword["keyword_text"],
"match_type": keyword["match_type"],
"keyword_status": keyword["keyword_status"],
"approval_status": keyword["approval_status"],
"quality_score": keyword["quality_score"],
"flags": keyword["flags"],
"recommendation": "sprawdz status i przyczyne ograniczenia slowa kluczowego w Google Ads",
}
)
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
problem_items.sort(
key=lambda row: (
severity_order.get(row["severity"], 9),
row["campaign_name"],
row["ad_group_name"],
row["keyword_text"],
)
)
return problem_items
def build_keyword_status_plan(client_config: ClientConfig) -> KeywordStatusPlan:
warnings = []
try:
keywords = fetch_keywords(client_config)
except Exception as exc:
keywords = []
warnings.append(f"Nie udalo sie pobrac slow kluczowych z Google Ads API: {exc}")
if not keywords:
warnings.append("Nie znaleziono slow kluczowych albo nie udalo sie ich pobrac.")
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace statusow slow kluczowych bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return KeywordStatusPlan(
keywords=keywords,
status_summary=build_counter_summary(keywords, "keyword_status", "status"),
approval_summary=build_counter_summary(keywords, "approval_status", "approval_status"),
quality_summary=build_counter_summary(keywords, "quality_bucket", "quality_bucket"),
problem_items=build_problem_items(keywords),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_keyword_status_plan(domain: str, plan: KeywordStatusPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie statusow slow kluczowych",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Slowa kluczowe: {len(plan.keywords)}",
f"- Elementy do oceny: {len(plan.problem_items)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.problem_items:
lines.extend(
[
"## Elementy do oceny",
"",
"| Waznosc | Kampania | Grupa reklam | Slowo | Dopasowanie | Status | Polityka | QS | Flagi | Rekomendacja |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.problem_items:
lines.append(
f"| {item['severity']} | {md_cell(item['campaign_name'])} | {md_cell(item['ad_group_name'])} | "
f"{md_cell(item['keyword_text'])} | {item['match_type']} | {item['keyword_status']} | "
f"{item['approval_status']} | {item['quality_score']} | {md_cell(', '.join(item['flags']))} | "
f"{md_cell(item['recommendation'])} |"
)
lines.append("")
if plan.status_summary:
lines.extend(["## Statusy slow kluczowych", "", "| Status | Liczba |", "| --- | --- |"])
for row in plan.status_summary:
lines.append(f"| {row['status']} | {row['count']} |")
lines.append("")
if plan.approval_summary:
lines.extend(["## Statusy zatwierdzenia", "", "| Status polityki | Liczba |", "| --- | --- |"])
for row in plan.approval_summary:
lines.append(f"| {row['approval_status']} | {row['count']} |")
lines.append("")
if plan.quality_summary:
lines.extend(["## Quality score", "", "| Koszyk | Liczba |", "| --- | --- |"])
for row in plan.quality_summary:
lines.append(f"| {row['quality_bucket']} | {row['count']} |")
lines.append("")
if plan.keywords:
lines.extend(
[
"## Slowa kluczowe",
"",
"| Kampania | Grupa reklam | Slowo | Dopasowanie | Status | Polityka | QS | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for keyword in plan.keywords:
lines.append(
f"| {md_cell(keyword['campaign_name'])} | {md_cell(keyword['ad_group_name'])} | "
f"{md_cell(keyword['keyword_text'])} | {keyword['match_type']} | {keyword['keyword_status']} | "
f"{keyword['approval_status']} | {keyword['quality_score']} | {md_cell(', '.join(keyword['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_keyword_status_plan(plan: KeywordStatusPlan) -> None:
print("\nPlan sprawdzenia statusow slow kluczowych")
print_table(
["Metryka", "Liczba"],
[
["Slowa kluczowe", str(len(plan.keywords))],
["Elementy do oceny", str(len(plan.problem_items))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.problem_items:
print("\nElementy do oceny")
print_table(
["Nr", "Waznosc", "Kampania", "Grupa reklam", "Slowo", "Status", "Polityka", "QS", "Flagi"],
[
[
str(index),
item["severity"],
item["campaign_name"],
item["ad_group_name"],
item["keyword_text"],
item["keyword_status"],
item["approval_status"],
str(item["quality_score"]),
", ".join(item["flags"]),
]
for index, item in enumerate(plan.problem_items[:30], 1)
],
)
if len(plan.problem_items) > 30:
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
if plan.status_summary:
print("\nStatusy slow kluczowych")
print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.status_summary])
if plan.approval_summary:
print("\nStatusy zatwierdzenia")
print_table(
["Status polityki", "Liczba"],
[[row["approval_status"], str(row["count"])] for row in plan.approval_summary],
)
if plan.quality_summary:
print("\nQuality score")
print_table(["Koszyk", "Liczba"], [[row["quality_bucket"], str(row["count"])] for row in plan.quality_summary])
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_keyword_status_plan(
client_config: ClientConfig,
plan: KeywordStatusPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem statusow slow kluczowych i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
"summary": {
"keywords": len(plan.keywords),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_keyword_statuses(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = KeywordStatusPlan.from_dict(plan_data)
print_keyword_status_plan(plan)
apply_keyword_status_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia statusow slow kluczowych...")
plan = build_keyword_status_plan(client_config)
print_keyword_status_plan(plan)
json_path, md_path = save_keyword_status_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(item["campaign_name"] for item in plan.problem_items[:10]),
"summary": {
"keywords": len(plan.keywords),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu statusow slow kluczowych.")
if show_navigation:
print_next_navigation(client_config.domain)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,367 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from google.protobuf import field_mask_pb2
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
@dataclass
class SettingsPlan:
campaigns: list[dict]
changes: list[dict]
skipped_rules: list[str]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": "check_pla_settings",
"campaigns": self.campaigns,
"changes": self.changes,
"skipped_rules": self.skipped_rules,
"warnings": self.warnings,
}
@classmethod
def from_dict(cls, data: dict) -> "SettingsPlan":
return cls(
campaigns=data.get("campaigns", []),
changes=data.get("changes", []),
skipped_rules=data.get("skipped_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def priority_name(value) -> str:
raw = enum_name(value)
return {"0": "LOW", "1": "MEDIUM", "2": "HIGH"}.get(raw, raw)
def human_geo(value: str) -> str:
return {
"PRESENCE": "Obecność",
"PRESENCE_OR_INTEREST": "Obecność lub zainteresowanie",
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
}.get(value, value)
def human_priority(value: str) -> str:
return {
"LOW": "Niski",
"MEDIUM": "Średni",
"HIGH": "Wysoki",
}.get(value, value)
def build_settings_plan(client_config: ClientConfig, global_rules: dict) -> SettingsPlan:
rules = client_config.effective_rules(global_rules, "pla_settings")
require_presence_only = bool(rules.get("require_presence_only", True))
require_high_priority = bool(rules.get("require_high_priority", True))
skipped_rules = []
if not require_presence_only:
skipped_rules.append("Regula lokalizacji Obecnosc jest wylaczona dla tego klienta.")
if not require_high_priority:
skipped_rules.append("Regula priorytetu wysokiego jest wylaczona dla tego klienta.")
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.geo_target_type_setting.positive_geo_target_type,
campaign.shopping_setting.campaign_priority
FROM campaign
WHERE campaign.advertising_channel_type = 'SHOPPING'
AND campaign.status = 'ENABLED'
""",
)
campaigns = []
changes = []
for row in rows:
campaign = row.campaign
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
priority = priority_name(campaign.shopping_setting.campaign_priority)
record = {
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"positive_geo_target_type": positive_geo,
"positive_geo_target_type_label": human_geo(positive_geo),
"campaign_priority": priority,
"campaign_priority_label": human_priority(priority),
}
campaigns.append(record)
if require_presence_only and positive_geo != "PRESENCE":
changes.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"setting": "lokalizacje",
"current_value": positive_geo,
"target_value": "PRESENCE",
"current_label": human_geo(positive_geo),
"target_label": human_geo("PRESENCE"),
"description": "Ustaw lokalizacje na Obecnosc.",
}
)
if require_high_priority and priority != "HIGH":
changes.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"setting": "priorytet kampanii",
"current_value": priority,
"target_value": "HIGH",
"current_label": human_priority(priority),
"target_label": human_priority("HIGH"),
"description": "Ustaw priorytet kampanii na wysoki.",
}
)
warnings = []
if not campaigns:
warnings.append("Nie znaleziono kampanii PLA.")
return SettingsPlan(campaigns=campaigns, changes=changes, skipped_rules=skipped_rules, warnings=warnings)
def save_settings_plan(domain: str, plan: SettingsPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_check_pla_settings"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie ustawien kampanii PLA",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie PLA: {len(plan.campaigns)}",
f"- Korekty do wdrozenia: {len(plan.changes)}",
"",
]
if plan.skipped_rules:
lines.extend(["## Wylaczone reguly", ""])
lines.extend(f"- {item}" for item in plan.skipped_rules)
lines.append("")
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Kampanie PLA", "", "| Kampania | Status | Lokalizacje | Priorytet |", "| --- | --- | --- | --- |"])
for row in plan.campaigns:
lines.append(
f"| {row['campaign_name']} | {row['status']} | "
f"{row.get('positive_geo_target_type_label', row['positive_geo_target_type'])} | "
f"{row.get('campaign_priority_label', row['campaign_priority'])} |"
)
lines.append("")
if plan.changes:
lines.extend(["## Planowane korekty", "", "| Kampania | Ustawienie | Obecnie | Docelowo |", "| --- | --- | --- | --- |"])
for row in plan.changes:
lines.append(
f"| {row['campaign_name']} | {row['setting']} | "
f"{row.get('current_label', row['current_value'])} | "
f"{row.get('target_label', row['target_value'])} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_settings_plan(plan: SettingsPlan) -> None:
print("\nPlan sprawdzenia ustawien kampanii PLA")
print(f"Kampanie PLA: {len(plan.campaigns)}")
print(f"Korekty do wdrozenia: {len(plan.changes)}")
for item in plan.skipped_rules:
print(f"Pominieto: {item}")
for item in plan.warnings:
print(f"Uwaga: {item}")
for change in plan.changes[:30]:
print(
f" {change['campaign_name']} | {change['setting']} | "
f"{change.get('current_label', change['current_value'])} -> "
f"{change.get('target_label', change['target_value'])}"
)
if len(plan.changes) > 30:
print(f" ... oraz {len(plan.changes) - 30} kolejnych korekt")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_settings_plan(client_config: ClientConfig, plan: SettingsPlan, show_navigation: bool = True) -> None:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
service = google_client.get_service("CampaignService")
changes_by_campaign: dict[str, dict] = {}
for change in plan.changes:
changes_by_campaign.setdefault(
change["campaign_id"],
{"campaign_name": change["campaign_name"], "settings": set()},
)
changes_by_campaign[change["campaign_id"]]["settings"].add(change["setting"])
operations = []
for campaign_id, row in changes_by_campaign.items():
op = google_client.get_type("CampaignOperation")
campaign = op.update
campaign.resource_name = service.campaign_path(customer_id, campaign_id)
paths = []
if "lokalizacje" in row["settings"]:
campaign.geo_target_type_setting.positive_geo_target_type = (
google_client.enums.PositiveGeoTargetTypeEnum.PRESENCE
)
paths.append("geo_target_type_setting.positive_geo_target_type")
if "priorytet kampanii" in row["settings"]:
campaign.shopping_setting.campaign_priority = 2
paths.append("shopping_setting.campaign_priority")
op.update_mask = field_mask_pb2.FieldMask(paths=paths)
operations.append(op)
changed = 0
if operations:
response = service.mutate_campaigns(customer_id=customer_id, operations=operations)
changed = len(response.results)
print("\nWynik wdrozenia zmian")
print(f"Zmieniono kampanii: {changed}")
print(f"Korekty ustawien: {len(plan.changes)}")
rows = [
{
"klient": client_config.domain,
"kampania": change["campaign_name"],
"czynnosc": change["description"],
"grupa reklam": "",
"produkt": f"{change.get('current_label', change['current_value'])} -> {change.get('target_label', change['target_value'])}",
}
for change in plan.changes
]
changes_path = append_change_markdown(client_config.domain, "Sprawdzenie ustawien kampanii PLA", rows)
history_path = append_history(
client_config.domain,
{
"task": "Sprawdzenie ustawien",
"status": "wdrozono zmiany",
"campaign": ", ".join(sorted({change["campaign_name"] for change in plan.changes})[:10]),
"summary": {"campaigns_changed": changed, "settings_changes": len(plan.changes)},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_pla_settings(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if apply_plan_path:
if confirm_apply != "TAK":
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = SettingsPlan.from_dict(plan_data)
print_settings_plan(plan)
apply_settings_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Sprawdzam ustawienia kampanii PLA...")
plan = build_settings_plan(client_config, global_rules)
print_settings_plan(plan)
json_path, md_path = save_settings_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": "Sprawdzenie ustawien",
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {"campaigns": len(plan.campaigns), "changes": len(plan.changes)},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
if not plan.changes:
print("\nBrak zmian do wdrozenia.")
append_change_markdown(client_config.domain, "Sprawdzenie ustawien kampanii PLA", [])
if show_navigation:
print_next_navigation(client_config.domain)
return
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
if answer != "TAK":
print("Przerwano. Zmiany nie zostaly wdrozone.")
append_history(
client_config.domain,
{
"task": "Sprawdzenie ustawien",
"status": "odrzucono wdrozenie",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
},
)
if show_navigation:
print_next_navigation(client_config.domain)
return
apply_settings_plan(client_config, plan, show_navigation=show_navigation)

View File

@@ -0,0 +1,543 @@
from __future__ import annotations
import json
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_pmax_structure"
TASK_NAME = "Sprawdzenie struktury PMax"
SCOPE = [
{
"area": "Kampanie PMax",
"check": "Pokaz aktywne i wstrzymane kampanie Performance Max oraz ich podstawowe wyniki z ostatnich 30 dni.",
},
{
"area": "Asset groups",
"check": "Policz asset groups w kazdej kampanii i wskaz kampanie wymagajace recznej oceny struktury.",
},
{
"area": "Feed produktowy",
"check": "Oznacz, ze ocena feedu produktowego jest polaczona z osobnym zadaniem Feed i Merchant Center.",
},
{
"area": "Brand / non-brand",
"check": "Wskaz kampanie, ktore po nazwie moga wymagac recznej oceny podzialu brand/non-brand.",
},
{
"area": "Kanibalizacja",
"check": "Wypisz ryzyko kanibalizacji Search, Shopping i remarketingu jako punkt do recznej oceny.",
},
]
OUT_OF_SCOPE = [
"budzety i pacing budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy RSA i zasoby Search",
"wdrazanie zmian w kampaniach Performance Max",
]
@dataclass
class PmaxStructurePlan:
currency_code: str
campaigns: list[dict]
asset_groups: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"campaigns": self.campaigns,
"asset_groups": self.asset_groups,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "PmaxStructurePlan":
return cls(
currency_code=data.get("currency_code", ""),
campaigns=data.get("campaigns", []),
asset_groups=data.get("asset_groups", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def safe_float(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def format_decimal(value: int | float) -> str:
return f"{float(value or 0):.2f}"
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def pmax_risks(campaign_name: str, asset_group_count: int, conversions_30d: float) -> list[str]:
name = campaign_name.casefold()
risks = []
if asset_group_count == 0:
risks.append("brak asset groups")
if asset_group_count == 1:
risks.append("jedna asset group")
if any(token in name for token in ["brand", "branded", "marka"]):
risks.append("sprawdz brand/non-brand")
if conversions_30d <= 0:
risks.append("brak konwersji w 30 dni")
risks.append("sprawdz kanibalizacje Search/Shopping")
return risks
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def fetch_pmax_asset_groups(google_client, customer_id: str, warnings: list[str]) -> list[dict]:
try:
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
asset_group.id,
asset_group.name,
asset_group.status
FROM asset_group
WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
AND campaign.status != 'REMOVED'
AND asset_group.status != 'REMOVED'
""",
)
except Exception as exc:
warnings.append(f"Nie udalo sie pobrac asset groups PMax: {exc}")
return []
asset_groups = []
for row in rows:
asset_groups.append(
{
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"asset_group_id": str(row.asset_group.id),
"asset_group_name": row.asset_group.name,
"status": enum_name(row.asset_group.status),
}
)
return asset_groups
def fetch_pmax_campaigns(client_config: ClientConfig) -> tuple[str, list[dict], list[dict], list[str]]:
warnings: list[str] = []
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
asset_groups = fetch_pmax_asset_groups(google_client, customer_id, warnings)
asset_groups_by_campaign: dict[str, list[dict]] = defaultdict(list)
for asset_group in asset_groups:
asset_groups_by_campaign[asset_group["campaign_id"]].append(asset_group)
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.bidding_strategy_type,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM campaign
WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
AND campaign.status != 'REMOVED'
AND segments.date DURING LAST_30_DAYS
""",
)
campaigns = []
for row in rows:
campaign_id = str(row.campaign.id)
asset_group_count = len(asset_groups_by_campaign.get(campaign_id, []))
conversions_30d = safe_float(row.metrics.conversions)
campaigns.append(
{
"campaign_id": campaign_id,
"campaign_name": row.campaign.name,
"status": enum_name(row.campaign.status),
"channel_type": enum_name(row.campaign.advertising_channel_type),
"bidding_strategy_type": enum_name(row.campaign.bidding_strategy_type),
"cost_30d_micros": safe_int(row.metrics.cost_micros),
"conversions_30d": conversions_30d,
"conversion_value_30d": safe_float(row.metrics.conversions_value),
"asset_group_count": asset_group_count,
"risk_labels": pmax_risks(row.campaign.name, asset_group_count, conversions_30d),
}
)
return currency_code, campaigns, asset_groups, warnings
def build_pmax_structure_plan(client_config: ClientConfig) -> PmaxStructurePlan:
warnings = []
try:
currency_code, campaigns, asset_groups, fetch_warnings = fetch_pmax_campaigns(client_config)
warnings.extend(fetch_warnings)
except Exception as exc:
currency_code = ""
campaigns = []
asset_groups = []
warnings.append(f"Nie udalo sie pobrac kampanii Performance Max z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii Performance Max z danymi z ostatnich 30 dni albo nie udalo sie ich pobrac.")
warnings.append(
"Informacje o feedzie produktowym i problemach produktow sprawdzaj w osobnym zadaniu Feed i Merchant Center."
)
warnings.append(
"Kanibalizacja Search/Shopping/remarketing wymaga recznej oceny z innymi zadaniami, a nie automatycznej decyzji."
)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace Performance Max bedziemy dopisywac osobno po akceptacji uzytkownika."
)
campaigns.sort(key=lambda row: (row["status"], row["campaign_name"]))
return PmaxStructurePlan(
currency_code=currency_code,
campaigns=campaigns,
asset_groups=asset_groups,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_pmax_structure_plan(domain: str, plan: PmaxStructurePlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie struktury PMax",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie PMax: {len(plan.campaigns)}",
f"- Asset groups: {len(plan.asset_groups)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie Performance Max",
"",
"| Kampania | Status | Asset groups | Koszt 30 dni | Konwersje | Wartosc konwersji | Ryzyka |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['status']} | "
f"{campaign['asset_group_count']} | {format_money(campaign['cost_30d_micros'], plan.currency_code)} | "
f"{format_decimal(campaign['conversions_30d'])} | {format_decimal(campaign['conversion_value_30d'])} | "
f"{md_cell(', '.join(campaign['risk_labels']))} |"
)
lines.append("")
if plan.asset_groups:
lines.extend(
[
"## Asset groups",
"",
"| Kampania | Asset group | Status |",
"| --- | --- | --- |",
]
)
for asset_group in plan.asset_groups:
lines.append(
f"| {md_cell(asset_group['campaign_name'])} | "
f"{md_cell(asset_group['asset_group_name'])} | {asset_group['status']} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_pmax_structure_plan(plan: PmaxStructurePlan) -> None:
print("\nPlan sprawdzenia struktury PMax")
print_table(
["Metryka", "Liczba"],
[
["Kampanie PMax", str(len(plan.campaigns))],
["Asset groups", str(len(plan.asset_groups))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.campaigns:
print("\nKampanie Performance Max")
print_table(
["Nr", "Kampania", "Status", "Asset groups", "Koszt 30 dni", "Konw.", "Ryzyka"],
[
[
str(index),
campaign["campaign_name"],
campaign["status"],
str(campaign["asset_group_count"]),
format_money(campaign["cost_30d_micros"], plan.currency_code),
format_decimal(campaign["conversions_30d"]),
", ".join(campaign["risk_labels"]),
]
for index, campaign in enumerate(plan.campaigns, 1)
],
)
if plan.asset_groups:
print("\nAsset groups")
print_table(
["Nr", "Kampania", "Asset group", "Status"],
[
[str(index), row["campaign_name"], row["asset_group_name"], row["status"]]
for index, row in enumerate(plan.asset_groups[:30], 1)
],
)
if len(plan.asset_groups) > 30:
print(f"... oraz {len(plan.asset_groups) - 30} kolejnych asset groups w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_pmax_structure_plan(
client_config: ClientConfig,
plan: PmaxStructurePlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem struktury PMax i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"asset_groups": len(plan.asset_groups),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_pmax_structure(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = PmaxStructurePlan.from_dict(plan_data)
print_pmax_structure_plan(plan)
apply_pmax_structure_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia struktury PMax...")
plan = build_pmax_structure_plan(client_config)
print_pmax_structure_plan(plan)
json_path, md_path = save_pmax_structure_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"asset_groups": len(plan.asset_groups),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu struktury PMax.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,731 @@
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import requests
from ..config import ClientConfig, client_dir
from ..history import append_change_markdown, append_history, now_local
from ..table import print_table
TASK_ID = "optimize_product_feed"
TASK_NAME = "Optymalizacja feed produktow"
TASK_TITLES_ID = "optimize_product_titles"
TASK_TITLES_NAME = "Optymalizacja tytulow produktow"
TASK_CATEGORIES_ID = "optimize_product_categories"
TASK_CATEGORIES_NAME = "Optymalizacja kategorii Google"
TASK_UNIT_PRICING_ID = "fill_product_unit_pricing"
TASK_UNIT_PRICING_NAME = "Uzupelnienie unit pricing"
@dataclass
class ProductFeedPlan:
products: list[dict]
title_changes: list[dict]
category_changes: list[dict]
unit_pricing_changes: list[dict]
skipped: list[dict]
warnings: list[str]
task_id: str = TASK_ID
task_name: str = TASK_NAME
def to_dict(self) -> dict:
return {
"task": self.task_id,
"task_name": self.task_name,
"products": self.products,
"title_changes": self.title_changes,
"category_changes": self.category_changes,
"unit_pricing_changes": self.unit_pricing_changes,
"skipped": self.skipped,
"warnings": self.warnings,
}
@classmethod
def from_dict(cls, data: dict) -> "ProductFeedPlan":
return cls(
products=data.get("products", []),
title_changes=data.get("title_changes", []),
category_changes=data.get("category_changes", []),
unit_pricing_changes=data.get("unit_pricing_changes", data.get("unit_pricing_previews", [])),
skipped=data.get("skipped", []),
warnings=data.get("warnings", []),
task_id=data.get("task", TASK_ID),
task_name=data.get("task_name", TASK_NAME),
)
def adspro_credentials(client_config: ClientConfig) -> tuple[str, str, str]:
api_url = os.environ.get("ADSPRO_API_URL")
api_key = os.environ.get("ADSPRO_API_KEY")
if not api_url or not api_key:
raise RuntimeError("Brak ADSPRO_API_URL lub ADSPRO_API_KEY w .env.")
if not client_config.adspro_client_id:
raise RuntimeError(f"Brak adspro_client_id dla {client_config.domain} w config/clients.toml.")
return api_url, api_key, client_config.adspro_client_id
def adspro_request(api_url: str, payload: dict, timeout: int = 30) -> dict:
response = requests.post(api_url, data=payload, timeout=timeout)
response.raise_for_status()
response.encoding = "utf-8"
data = response.json()
if data.get("result") == "error":
raise RuntimeError(data.get("message") or "adsPRO zwrocil blad.")
return data
def fetch_products_by_action(client_config: ClientConfig, action: str, limit: int) -> list[dict]:
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
data = adspro_request(
api_url,
{
"action": action,
"api_key": api_key,
"client_id": adspro_client_id,
"limit": str(limit),
},
timeout=60,
)
return data.get("products", [])
def fetch_missing_title_products(client_config: ClientConfig, limit: int) -> list[dict]:
return fetch_products_by_action(client_config, "products_get_missing_title", limit)
def fetch_missing_category_products(client_config: ClientConfig, limit: int) -> list[dict]:
return fetch_products_by_action(client_config, "products_get_missing_google_category", limit)
def fetch_missing_unit_pricing_products(client_config: ClientConfig, limit: int) -> list[dict]:
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
data = adspro_request(
api_url,
{
"action": "products_get_missing_unit_pricing",
"api_key": api_key,
"client_id": adspro_client_id,
"top": str(limit),
},
timeout=60,
)
return data.get("products", [])
def merge_products(*groups: list[dict]) -> list[dict]:
merged: dict[str, dict] = {}
for products in groups:
for product in products:
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
if not offer_id:
continue
merged.setdefault(offer_id, {}).update(product)
return list(merged.values())
def changelog_path(domain: str) -> Path:
return client_dir(domain) / "produkty_changelog.jsonl"
def read_product_changelog(domain: str) -> list[dict]:
path = changelog_path(domain)
if not path.exists():
return []
entries = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def latest_title_change_dates(domain: str) -> dict[str, date]:
latest: dict[str, date] = {}
for entry in read_product_changelog(domain):
if entry.get("field") != "title":
continue
product_id = str(entry.get("product_id") or entry.get("offer_id") or "")
if not product_id:
continue
try:
changed_at = date.fromisoformat(str(entry.get("date")))
except ValueError:
continue
if product_id not in latest or changed_at > latest[product_id]:
latest[product_id] = changed_at
return latest
def append_product_changelog(domain: str, rows: list[dict]) -> Path:
path = changelog_path(domain)
path.parent.mkdir(parents=True, exist_ok=True)
today = now_local().date().isoformat()
with path.open("a", encoding="utf-8") as f:
for row in rows:
f.write(
json.dumps(
{
"product_id": row["offer_id"],
"date": today,
"field": row["field"],
"old": row.get("current_value", ""),
"new": row.get("target_value", ""),
},
ensure_ascii=False,
)
+ "\n"
)
return path
def clean_title(value: str) -> str:
value = re.sub(r"\s+", " ", value or "").strip()
value = value.replace(" - - ", " - ")
if value.isupper() and len(value) > 12:
value = value.title()
return value[:150].strip()
def suggest_title(product: dict) -> str:
source = product.get("default_name") or product.get("title") or product.get("custom_title") or ""
title = clean_title(source)
brand = clean_title(product.get("brand") or "")
if brand and title and not title.lower().startswith(brand.lower()):
title = clean_title(f"{brand} {title}")
return title
def unit_pricing_preview(title: str) -> dict | None:
match = re.search(r"(\d+(?:[,.]\d+)?)\s*(ml|l|g|kg|szt|sztuk|caps|kaps)\b", title or "", re.IGNORECASE)
if not match:
return None
amount = match.group(1).replace(",", ".")
unit = match.group(2).lower()
unit = {"sztuk": "szt", "caps": "szt", "kaps": "szt"}.get(unit, unit)
if unit in {"ml", "l"}:
base = "100 ml" if unit == "ml" else "1 l"
elif unit in {"g", "kg"}:
base = "100 g" if unit == "g" else "1 kg"
else:
base = "1 szt"
return {
"unit_pricing_measure": f"{amount} {unit}",
"unit_pricing_base_measure": base,
}
def build_product_feed_plan(
client_config: ClientConfig,
global_rules: dict,
scope: str = "all",
task_id: str = TASK_ID,
task_name: str = TASK_NAME,
) -> ProductFeedPlan:
rules = client_config.effective_rules(global_rules, "product_feed_optimization")
limit = int(rules.get("limit", 10))
min_days_between_title_changes = int(rules.get("min_days_between_title_changes", 30))
title_products = fetch_missing_title_products(client_config, limit) if scope in {"all", "titles"} else []
category_products = fetch_missing_category_products(client_config, limit) if scope in {"all", "categories"} else []
unit_products = fetch_missing_unit_pricing_products(client_config, limit) if scope in {"all", "unit_pricing"} else []
products = merge_products(title_products, category_products, unit_products)
latest_changes = latest_title_change_dates(client_config.domain)
today = now_local().date()
title_changes = []
category_changes = []
unit_changes = []
skipped = []
warnings = []
for product in title_products:
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
default_title = product.get("default_name") or product.get("title") or ""
if not offer_id:
skipped.append({"offer_id": "", "reason": "produkt bez offer_id"})
continue
changed_at = latest_changes.get(offer_id)
if changed_at and (today - changed_at).days < min_days_between_title_changes:
skipped.append(
{
"offer_id": offer_id,
"reason": f"tytul zmieniony {(today - changed_at).days} dni temu, minimum {min_days_between_title_changes}",
}
)
continue
needs_title = bool(product.get("needs_title")) or not product.get("title_changed")
proposed_title = suggest_title(product)
current_title = product.get("custom_title") or default_title
if needs_title and proposed_title and proposed_title != current_title:
title_changes.append(
{
"offer_id": offer_id,
"field": "title",
"current_value": current_title,
"target_value": proposed_title,
"reason": "brak zoptymalizowanego tytulu lub tytul wymaga normalizacji",
}
)
elif needs_title:
title_changes.append(
{
"offer_id": offer_id,
"field": "title",
"current_value": current_title,
"target_value": "",
"reason": "brak zoptymalizowanego tytulu; tytul wybiera agent AI po analizie produktu",
"requires_agent_decision": True,
}
)
for product in category_products:
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
if not offer_id:
skipped.append({"offer_id": "", "reason": "produkt bez offer_id"})
continue
current_category = product.get("google_product_category") or product.get("google_category") or ""
category_changes.append(
{
"offer_id": offer_id,
"field": "google_product_category",
"current_value": current_category,
"target_value": "",
"reason": "brak kategorii Google; kategorie wybiera agent AI po analizie produktu",
"requires_agent_decision": True,
}
)
seen_unit_offer_ids = set()
for product in unit_products:
offer_id = str(product.get("offer_id") or product.get("id") or "").strip()
default_title = product.get("default_name") or product.get("title") or product.get("custom_title") or ""
if not offer_id or offer_id in seen_unit_offer_ids:
continue
seen_unit_offer_ids.add(offer_id)
preview = unit_pricing_preview(default_title)
if not preview:
skipped.append({"offer_id": offer_id, "reason": "brak jednoznacznego unit pricing w nazwie produktu"})
continue
unit_changes.append(
{
"offer_id": offer_id,
"field": "unit_pricing",
"title": default_title,
"current_unit_pricing_measure": product.get("unit_pricing_measure") or "",
"current_unit_pricing_base_measure": product.get("unit_pricing_base_measure") or "",
"unit_pricing_measure": preview["unit_pricing_measure"],
"unit_pricing_base_measure": preview["unit_pricing_base_measure"],
"reason": "brak unit pricing; wartosc wyliczona z nazwy produktu",
}
)
if scope == "titles" and not title_products:
warnings.append("adsPRO nie zwrocil produktow bez zoptymalizowanego tytulu.")
if scope == "categories" and not category_products:
warnings.append("adsPRO nie zwrocil produktow bez kategorii Google.")
if scope == "all" and not products:
warnings.append("adsPRO nie zwrocil produktow do optymalizacji.")
if scope == "unit_pricing" and not unit_products:
warnings.append("adsPRO nie zwrocil produktow bez unit pricing.")
if category_changes:
warnings.append("Kategorie Google wybiera agent AI; skrypt nie zgaduje ich automatycznie.")
if title_changes and any(row.get("requires_agent_decision") for row in title_changes):
warnings.append("Czesc tytulow wymaga decyzji agenta AI; skrypt nie przepisuje tytulu bazowego jako optymalizacji.")
if unit_changes:
warnings.append("Unit pricing zostanie zapisany w adsPRO dopiero po akceptacji planu.")
return ProductFeedPlan(
products=products,
title_changes=title_changes,
category_changes=category_changes,
unit_pricing_changes=unit_changes,
skipped=skipped,
warnings=warnings,
task_id=task_id,
task_name=task_name,
)
def save_product_feed_plan(domain: str, plan: ProductFeedPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{plan.task_id}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
f"# Plan: {plan.task_name}",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Produkty z adsPRO: {len(plan.products)}",
f"- Tytuly do zmiany: {len(plan.title_changes)}",
f"- Kategorie do uzupelnienia: {len(plan.category_changes)}",
f"- Unit pricing do zmiany: {len(plan.unit_pricing_changes)}",
f"- Pominiete: {len(plan.skipped)}",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
if plan.title_changes:
lines.extend(["## Tytuly do decyzji lub zmiany", "", "| Produkt | Obecnie | Docelowo | Powod |", "| --- | --- | --- | --- |"])
for row in plan.title_changes:
lines.append(f"| {row['offer_id']} | {row['current_value']} | {row['target_value']} | {row['reason']} |")
lines.append("")
if plan.category_changes:
lines.extend(["## Kategorie Google do decyzji agenta AI", "", "| Produkt | Obecnie | Decyzja agenta AI | Powod |", "| --- | --- | --- | --- |"])
for row in plan.category_changes:
lines.append(f"| {row['offer_id']} | {row['current_value']} | {row['target_value']} | {row['reason']} |")
lines.append("")
if plan.unit_pricing_changes:
lines.extend(["## Unit pricing do zmiany", "", "| Produkt | Measure | Base measure | Powod |", "| --- | --- | --- | --- |"])
for row in plan.unit_pricing_changes:
lines.append(
f"| {row['offer_id']} | {row['unit_pricing_measure']} | {row['unit_pricing_base_measure']} | {row['reason']} |"
)
lines.append("")
if plan.skipped:
lines.extend(["## Pominiete", "", "| Produkt | Powod |", "| --- | --- |"])
for row in plan.skipped:
lines.append(f"| {row.get('offer_id', '')} | {row.get('reason', '')} |")
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_product_feed_plan(plan: ProductFeedPlan) -> None:
print(f"\nPlan: {plan.task_name}")
print_table(
["Zakres", "Liczba"],
[
["Produkty z adsPRO", str(len(plan.products))],
["Tytuly do zmiany", str(len(plan.title_changes))],
["Kategorie do uzupelnienia", str(len(plan.category_changes))],
["Unit pricing do zmiany", str(len(plan.unit_pricing_changes))],
["Pominiete", str(len(plan.skipped))],
],
)
if plan.title_changes:
print("\nNajwazniejsze dzialania")
rows = [
[str(i), row["offer_id"], "Zmien tytul", row["target_value"]]
for i, row in enumerate(plan.title_changes[:10], 1)
]
print_table(["Nr", "Produkt", "Dzialanie", "Docelowo"], rows)
if len(plan.title_changes) > 10:
print(f"... oraz {len(plan.title_changes) - 10} kolejnych zmian tytulow")
for warning in plan.warnings:
print(f"Uwaga: {warning}")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def set_product_title(api_url: str, api_key: str, client_id: str, row: dict) -> None:
adspro_request(
api_url,
{
"action": "product_title_set",
"api_key": api_key,
"client_id": client_id,
"offer_id": row["offer_id"],
"title": row["target_value"],
},
)
def set_product_category(api_url: str, api_key: str, client_id: str, row: dict) -> None:
adspro_request(
api_url,
{
"action": "product_google_category_set",
"api_key": api_key,
"client_id": client_id,
"offer_id": row["offer_id"],
"google_product_category": row["target_value"],
},
)
def set_product_unit_pricing(api_url: str, api_key: str, client_id: str, row: dict) -> None:
adspro_request(
api_url,
{
"action": "product_unit_pricing_set",
"api_key": api_key,
"client_id": client_id,
"offer_id": row["offer_id"],
"unit_pricing_measure": row["unit_pricing_measure"],
"unit_pricing_base_measure": row["unit_pricing_base_measure"],
},
)
def apply_product_feed_plan(client_config: ClientConfig, plan: ProductFeedPlan, show_navigation: bool = True) -> None:
api_url, api_key, adspro_client_id = adspro_credentials(client_config)
applied = []
skipped = []
for row in plan.title_changes:
if not row.get("target_value"):
skipped.append({**row, "skip_reason": "brak target_value"})
continue
set_product_title(api_url, api_key, adspro_client_id, row)
applied.append(row)
for row in plan.category_changes:
if not row.get("target_value"):
skipped.append({**row, "skip_reason": "brak target_value"})
continue
set_product_category(api_url, api_key, adspro_client_id, row)
applied.append(row)
for row in plan.unit_pricing_changes:
if not row.get("unit_pricing_measure") or not row.get("unit_pricing_base_measure"):
skipped.append({**row, "skip_reason": "brak unit_pricing_measure lub unit_pricing_base_measure"})
continue
set_product_unit_pricing(api_url, api_key, adspro_client_id, row)
applied.append(
{
"offer_id": row["offer_id"],
"field": "unit_pricing",
"current_value": (
f"{row.get('current_unit_pricing_measure', '')} / "
f"{row.get('current_unit_pricing_base_measure', '')}"
).strip(" /"),
"target_value": f"{row['unit_pricing_measure']} / {row['unit_pricing_base_measure']}",
}
)
print("\nWynik wdrozenia zmian")
print(f"Wdrozono zmian: {len(applied)}")
print(f"Pominieto: {len(skipped)}")
change_rows = [
{
"klient": client_config.domain,
"produkt": row["offer_id"],
"pole": row["field"],
"obecnie": row.get("current_value", ""),
"docelowo": row.get("target_value", ""),
}
for row in applied
]
changes_path = append_change_markdown(client_config.domain, plan.task_name, change_rows)
history_path = append_history(
client_config.domain,
{
"task": plan.task_name,
"status": "wdrozono zmiany",
"product": ", ".join(row["offer_id"] for row in applied[:10]),
"summary": {"applied": len(applied), "skipped": len(skipped)},
},
)
changelog = append_product_changelog(client_config.domain, applied) if applied else changelog_path(client_config.domain)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
print(f"Changelog produktow: {changelog}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_product_feed_task(
client_config: ClientConfig,
global_rules: dict,
scope: str,
task_id: str,
task_name: str,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if apply_plan_path:
if confirm_apply != "TAK":
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = ProductFeedPlan.from_dict(plan_data)
print_product_feed_plan(plan)
apply_product_feed_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print(f"Pobieram produkty z adsPRO i przygotowuje plan: {task_name}...")
plan = build_product_feed_plan(client_config, global_rules, scope=scope, task_id=task_id, task_name=task_name)
print_product_feed_plan(plan)
json_path, md_path = save_product_feed_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": task_name,
"status": "plan przygotowany",
"product": ", ".join(str(product.get("offer_id") or product.get("id") or "") for product in plan.products[:10]),
"summary": {
"products": len(plan.products),
"title_changes": len(plan.title_changes),
"category_changes": len(plan.category_changes),
"unit_pricing_changes": len(plan.unit_pricing_changes),
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
if (
not plan.title_changes
and not any(row.get("target_value") for row in plan.category_changes)
and not plan.unit_pricing_changes
):
print("\nBrak gotowych zmian do wdrozenia.")
append_change_markdown(client_config.domain, task_name, [])
if show_navigation:
print_next_navigation(client_config.domain)
return
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
if answer != "TAK":
print("Przerwano. Zmiany nie zostaly wdrozone.")
append_history(
client_config.domain,
{
"task": task_name,
"status": "odrzucono wdrozenie",
"product": ", ".join(row["offer_id"] for row in plan.title_changes[:10]),
},
)
if show_navigation:
print_next_navigation(client_config.domain)
return
apply_product_feed_plan(client_config, plan, show_navigation=show_navigation)
def run_optimize_product_feed(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
run_product_feed_task(
client_config,
global_rules,
"all",
TASK_ID,
TASK_NAME,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
def run_optimize_product_titles(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
run_product_feed_task(
client_config,
global_rules,
"titles",
TASK_TITLES_ID,
TASK_TITLES_NAME,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
def run_optimize_product_categories(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
run_product_feed_task(
client_config,
global_rules,
"categories",
TASK_CATEGORIES_ID,
TASK_CATEGORIES_NAME,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
def run_fill_product_unit_pricing(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
run_product_feed_task(
client_config,
global_rules,
"unit_pricing",
TASK_UNIT_PRICING_ID,
TASK_UNIT_PRICING_NAME,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)

View File

@@ -0,0 +1,461 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_remarketing_setup"
TASK_NAME = "Sprawdzenie remarketingu"
SCOPE = [
{
"area": "Listy odbiorcow",
"check": "Pokaz listy odbiorcow z Google Ads, ich typ, status czlonkostwa i rozmiary dla Search oraz Display.",
},
{
"area": "Dynamiczny remarketing",
"check": "Wypisz kontrole tagowania produktowego i identyfikatorow produktow do recznej oceny.",
},
{
"area": "Powiazanie z PMax",
"check": "Oznacz ryzyko nakladania remarketingu z PMax jako punkt do recznej oceny.",
},
{
"area": "Gotowosc list",
"check": "Oznacz listy zbyt male albo zamkniete jako potencjalny problem wykorzystania w kampaniach.",
},
]
DYNAMIC_REMARKETING_CHECKS = [
"czy tag Google Ads / GTM przekazuje identyfikatory produktow zgodne z feedem",
"czy zdarzenia e-commerce rozrozniaja view_item, add_to_cart i purchase",
"czy remarketing dynamiczny ma dostep do feedu produktowego",
"czy listy odbiorcow sa wystarczajaco duze dla Search i Display",
"czy PMax nie przechwytuje calego remarketingu bez osobnej kontroli",
]
OUT_OF_SCOPE = [
"budzety i pacing budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"tworzenie reklam i kreacji remarketingowych",
"problemy feedu i Merchant Center poza kontrola identyfikatorow produktow",
"wdrazanie zmian w tagowaniu albo listach odbiorcow",
]
@dataclass
class RemarketingSetupPlan:
user_lists: list[dict]
list_type_summary: list[dict]
dynamic_remarketing_checks: list[str]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"user_lists": self.user_lists,
"list_type_summary": self.list_type_summary,
"dynamic_remarketing_checks": self.dynamic_remarketing_checks,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "RemarketingSetupPlan":
return cls(
user_lists=data.get("user_lists", []),
list_type_summary=data.get("list_type_summary", []),
dynamic_remarketing_checks=data.get("dynamic_remarketing_checks", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def size_label(search_size: int, display_size: int) -> str:
max_size = max(search_size, display_size)
if max_size <= 0:
return "brak rozmiaru"
if max_size < 100:
return "bardzo mala lista"
if max_size < 1000:
return "mala lista"
return "rozmiar ok"
def user_list_flags(row: dict) -> list[str]:
flags = []
if row["membership_status"] and row["membership_status"] != "OPEN":
flags.append("lista zamknieta")
if row["size_status"] != "rozmiar ok":
flags.append(row["size_status"])
if row["type"] in {"UNKNOWN", "UNSPECIFIED"}:
flags.append("nieznany typ")
return flags or ["ok"]
def fetch_user_lists(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
user_list.id,
user_list.name,
user_list.type,
user_list.membership_status,
user_list.size_for_search,
user_list.size_for_display
FROM user_list
""",
)
user_lists = []
for row in rows:
user_list = row.user_list
search_size = safe_int(user_list.size_for_search)
display_size = safe_int(user_list.size_for_display)
record = {
"user_list_id": str(user_list.id),
"name": user_list.name,
"type": enum_name(user_list.type),
"membership_status": enum_name(user_list.membership_status),
"size_for_search": search_size,
"size_for_display": display_size,
"size_status": size_label(search_size, display_size),
}
record["flags"] = user_list_flags(record)
user_lists.append(record)
user_lists.sort(key=lambda row: (row["size_status"], row["name"]))
return user_lists
def build_list_type_summary(user_lists: list[dict]) -> list[dict]:
counter = Counter(row["type"] for row in user_lists)
return [{"type": key, "count": value} for key, value in counter.most_common()]
def build_remarketing_setup_plan(client_config: ClientConfig) -> RemarketingSetupPlan:
warnings = []
try:
user_lists = fetch_user_lists(client_config)
except Exception as exc:
user_lists = []
warnings.append(f"Nie udalo sie pobrac list odbiorcow z Google Ads API: {exc}")
if not user_lists:
warnings.append("Nie znaleziono list odbiorcow albo nie udalo sie ich pobrac.")
warnings.append(
"Dynamiczny remarketing wymaga potwierdzenia tagowania produktowego w zadaniu Pomiar i konwersje oraz zgodnosci feedu w zadaniu Feed i Merchant Center."
)
warnings.append(
"Konflikt remarketingu z PMax wymaga recznej oceny razem z zadaniem Sprawdzenie struktury PMax."
)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace remarketingu bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return RemarketingSetupPlan(
user_lists=user_lists,
list_type_summary=build_list_type_summary(user_lists),
dynamic_remarketing_checks=DYNAMIC_REMARKETING_CHECKS,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_remarketing_setup_plan(domain: str, plan: RemarketingSetupPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie remarketingu",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Listy odbiorcow: {len(plan.user_lists)}",
f"- Typy list: {len(plan.list_type_summary)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.list_type_summary:
lines.extend(["## Podsumowanie typow list", "", "| Typ | Liczba |", "| --- | --- |"])
for row in plan.list_type_summary:
lines.append(f"| {row['type']} | {row['count']} |")
lines.append("")
if plan.user_lists:
lines.extend(
[
"## Listy odbiorcow",
"",
"| Lista | Typ | Status | Search size | Display size | Flagi |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for row in plan.user_lists:
lines.append(
f"| {md_cell(row['name'])} | {row['type']} | {row['membership_status']} | "
f"{row['size_for_search']} | {row['size_for_display']} | {md_cell(', '.join(row['flags']))} |"
)
lines.append("")
lines.extend(["## Kontrole dynamicznego remarketingu", ""])
lines.extend(f"- {item}" for item in plan.dynamic_remarketing_checks)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_remarketing_setup_plan(plan: RemarketingSetupPlan) -> None:
print("\nPlan sprawdzenia remarketingu")
print_table(
["Metryka", "Liczba"],
[
["Listy odbiorcow", str(len(plan.user_lists))],
["Typy list", str(len(plan.list_type_summary))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.list_type_summary:
print("\nPodsumowanie typow list")
print_table(
["Typ", "Liczba"],
[[row["type"], str(row["count"])] for row in plan.list_type_summary],
)
if plan.user_lists:
print("\nListy odbiorcow")
print_table(
["Nr", "Lista", "Typ", "Status", "Search", "Display", "Flagi"],
[
[
str(index),
row["name"],
row["type"],
row["membership_status"],
str(row["size_for_search"]),
str(row["size_for_display"]),
", ".join(row["flags"]),
]
for index, row in enumerate(plan.user_lists[:30], 1)
],
)
if len(plan.user_lists) > 30:
print(f"... oraz {len(plan.user_lists) - 30} kolejnych list w pliku planu")
print("\nKontrole dynamicznego remarketingu")
print_table(
["Nr", "Kontrola"],
[[str(index), item] for index, item in enumerate(plan.dynamic_remarketing_checks, 1)],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_remarketing_setup_plan(
client_config: ClientConfig,
plan: RemarketingSetupPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem remarketingu i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": "",
"summary": {
"user_lists": len(plan.user_lists),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_remarketing_setup(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = RemarketingSetupPlan.from_dict(plan_data)
print_remarketing_setup_plan(plan)
apply_remarketing_setup_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia remarketingu...")
plan = build_remarketing_setup_plan(client_config)
print_remarketing_setup_plan(plan)
json_path, md_path = save_remarketing_setup_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": "",
"summary": {
"user_lists": len(plan.user_lists),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu remarketingu.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,438 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_rsa_assets"
TASK_NAME = "Sprawdzenie reklam RSA i zasobow"
MAX_RSA_ADS = 100
SCOPE = [
{
"area": "Reklamy RSA",
"check": "Pokaz aktywne reklamy RSA w aktywnych kampaniach Search i grupach reklam.",
},
{
"area": "Naglowki",
"check": "Policz naglowki i oznacz reklamy z mala liczba wariantow albo duplikatami.",
},
{
"area": "Teksty reklam",
"check": "Policz opisy i oznacz reklamy z mala liczba wariantow.",
},
{
"area": "DKI",
"check": "Oznacz uzycie Dynamic Keyword Insertion jako element wymagajacy recznej oceny.",
},
{
"area": "Final URL",
"check": "Sprawdz, czy reklama ma finalny adres URL.",
},
]
OUT_OF_SCOPE = [
"zapytania uzytkownikow i wykluczenia",
"budzety i pacing budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"podstawowe ustawienia kampanii, np. lokalizacje i sieci",
"automatyczne tworzenie albo edycja reklam",
]
@dataclass
class RsaAssetsPlan:
ads: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"ads": self.ads,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "RsaAssetsPlan":
return cls(
ads=data.get("ads", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def text_asset_values(assets: Any) -> list[str]:
values = []
for asset in assets or []:
text = str(getattr(asset, "text", "") or "").strip()
if text:
values.append(text)
return values
def has_dki(values: list[str]) -> bool:
return any("{keyword:" in value.casefold() for value in values)
def duplicate_count(values: list[str]) -> int:
normalized = [value.casefold().strip() for value in values if value.strip()]
return len(normalized) - len(set(normalized))
def risk_labels(headlines: list[str], descriptions: list[str], final_urls: list[str]) -> list[str]:
risks = []
if len(headlines) < 8:
risks.append("malo naglowkow")
if len(descriptions) < 3:
risks.append("malo opisow")
if duplicate_count(headlines) > 0:
risks.append("duplikaty naglowkow")
if has_dki(headlines + descriptions):
risks.append("sprawdz DKI")
if not final_urls:
risks.append("brak final URL")
return risks or ["do oceny"]
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def fetch_rsa_ads(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
f"""
SELECT
campaign.id,
campaign.name,
ad_group.id,
ad_group.name,
ad_group_ad.ad.id,
ad_group_ad.status,
ad_group_ad.ad.final_urls,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions
FROM ad_group_ad
WHERE campaign.status = 'ENABLED'
AND ad_group.status = 'ENABLED'
AND ad_group_ad.status != 'REMOVED'
AND ad_group_ad.ad.type = 'RESPONSIVE_SEARCH_AD'
LIMIT {MAX_RSA_ADS}
""",
)
ads = []
for row in rows:
ad = row.ad_group_ad.ad
rsa = ad.responsive_search_ad
headlines = text_asset_values(rsa.headlines)
descriptions = text_asset_values(rsa.descriptions)
final_urls = [str(url) for url in ad.final_urls]
risks = risk_labels(headlines, descriptions, final_urls)
ads.append(
{
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"ad_group_id": str(row.ad_group.id),
"ad_group_name": row.ad_group.name,
"ad_id": str(ad.id),
"status": enum_name(row.ad_group_ad.status),
"headline_count": len(headlines),
"description_count": len(descriptions),
"headlines": headlines,
"descriptions": descriptions,
"final_urls": final_urls,
"has_dki": has_dki(headlines + descriptions),
"duplicate_headlines": duplicate_count(headlines),
"risk_labels": risks,
}
)
return ads
def build_rsa_assets_plan(client_config: ClientConfig) -> RsaAssetsPlan:
warnings = []
try:
ads = fetch_rsa_ads(client_config)
except Exception as exc:
ads = []
warnings.append(f"Nie udalo sie pobrac reklam RSA z Google Ads API: {exc}")
if not ads:
warnings.append("Nie znaleziono aktywnych reklam RSA albo nie udalo sie ich pobrac.")
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace RSA i zasobow bedziemy dopisywac osobno po akceptacji uzytkownika."
)
ads.sort(key=lambda row: (len(row["risk_labels"]), row["campaign_name"], row["ad_group_name"]), reverse=True)
return RsaAssetsPlan(
ads=ads,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_rsa_assets_plan(domain: str, plan: RsaAssetsPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie reklam RSA i zasobow",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Reklamy RSA: {len(plan.ads)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.ads:
lines.extend(
[
"## Reklamy RSA",
"",
"| Kampania | Grupa reklam | Reklama | Naglowki | Opisy | Ryzyka |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for ad in plan.ads:
lines.append(
f"| {md_cell(ad['campaign_name'])} | {md_cell(ad['ad_group_name'])} | "
f"{md_cell(ad['ad_id'])} | {ad['headline_count']} | {ad['description_count']} | "
f"{md_cell(', '.join(ad['risk_labels']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_rsa_assets_plan(plan: RsaAssetsPlan) -> None:
print("\nPlan sprawdzenia reklam RSA i zasobow")
print_table(
["Metryka", "Liczba"],
[
["Reklamy RSA", str(len(plan.ads))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.ads:
print("\nReklamy RSA")
print_table(
["Nr", "Kampania", "Grupa reklam", "Naglowki", "Opisy", "Ryzyka"],
[
[
str(index),
ad["campaign_name"],
ad["ad_group_name"],
str(ad["headline_count"]),
str(ad["description_count"]),
", ".join(ad["risk_labels"]),
]
for index, ad in enumerate(plan.ads[:30], 1)
],
)
if len(plan.ads) > 30:
print(f"... oraz {len(plan.ads) - 30} kolejnych reklam w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_rsa_assets_plan(
client_config: ClientConfig,
plan: RsaAssetsPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem reklam RSA i nie edytuje reklam na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(sorted({ad["campaign_name"] for ad in plan.ads})[:10]),
"summary": {
"ads": len(plan.ads),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_rsa_assets(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = RsaAssetsPlan.from_dict(plan_data)
print_rsa_assets_plan(plan)
apply_rsa_assets_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia reklam RSA i zasobow...")
plan = build_rsa_assets_plan(client_config)
print_rsa_assets_plan(plan)
json_path, md_path = save_rsa_assets_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(sorted({ad["campaign_name"] for ad in plan.ads})[:10]),
"summary": {
"ads": len(plan.ads),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu reklam RSA.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,482 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from google.protobuf import field_mask_pb2
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_search_basic_settings"
TASK_NAME = "Sprawdzenie podstawowych ustawien Search"
SCOPE = [
{
"area": "Lokalizacje",
"check": "Sprawdz typ kierowania lokalizacji, zwlaszcza Obecnosc vs Obecnosc lub zainteresowanie.",
},
{
"area": "Sieci",
"check": "Sprawdz, czy kampanie Search nie maja niechcaco wlaczonej sieci reklamowej albo partnerow wyszukiwania.",
},
{
"area": "Jezyki",
"check": "Sprawdz, czy ustawienia jezykowe sa zgodne z rynkiem klienta.",
},
{
"area": "Harmonogram reklam",
"check": "Sprawdz, czy harmonogram jest swiadomie ustawiony albo czy kampania dziala caly czas.",
},
]
OUT_OF_SCOPE = [
"budzety i wykorzystanie budzetu",
"strategie stawek i uczenie strategii",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy RSA i zasoby reklam",
"wyniki kampanii oraz rentownosc",
]
@dataclass
class SearchBasicSettingsPlan:
campaigns: list[dict]
changes: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"changes": self.changes,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
}
@classmethod
def from_dict(cls, data: dict) -> "SearchBasicSettingsPlan":
return cls(
campaigns=data.get("campaigns", []),
changes=data.get("changes", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def human_geo(value: str) -> str:
return {
"PRESENCE": "Obecnosc",
"PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie",
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
}.get(value, value)
def yes_no(value: bool) -> str:
return "TAK" if value else "NIE"
def md_cell(value) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def build_required_changes(campaign: dict) -> list[dict]:
changes = []
if campaign["positive_geo_target_type"] != "PRESENCE":
changes.append(
{
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"setting": "lokalizacje",
"current_value": campaign["positive_geo_target_type"],
"target_value": "PRESENCE",
"current_label": campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]),
"target_label": human_geo("PRESENCE"),
"description": "Ustaw lokalizacje na Obecnosc.",
}
)
if campaign["target_content_network"]:
changes.append(
{
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"setting": "siec reklamowa",
"current_value": "true",
"target_value": "false",
"current_label": "Wlaczona",
"target_label": "Wylaczona",
"description": "Wylacz siec reklamowa w kampanii Search.",
}
)
if campaign["target_partner_search_network"]:
changes.append(
{
"campaign_id": campaign["campaign_id"],
"campaign_name": campaign["campaign_name"],
"setting": "partnerzy wyszukiwania",
"current_value": "true",
"target_value": "false",
"current_label": "Wlaczeni",
"target_label": "Wylaczeni",
"description": "Wylacz partnerow wyszukiwania w kampanii Search.",
}
)
return changes
def fetch_search_campaigns(client_config: ClientConfig) -> list[dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.geo_target_type_setting.positive_geo_target_type,
campaign.network_settings.target_google_search,
campaign.network_settings.target_search_network,
campaign.network_settings.target_partner_search_network,
campaign.network_settings.target_content_network
FROM campaign
WHERE campaign.advertising_channel_type = 'SEARCH'
AND campaign.status != 'REMOVED'
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
campaigns.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"positive_geo_target_type": positive_geo,
"positive_geo_target_type_label": human_geo(positive_geo),
"target_google_search": bool(campaign.network_settings.target_google_search),
"target_search_network": bool(campaign.network_settings.target_search_network),
"target_partner_search_network": bool(campaign.network_settings.target_partner_search_network),
"target_content_network": bool(campaign.network_settings.target_content_network),
}
)
return campaigns
def build_search_basic_settings_plan(client_config: ClientConfig) -> SearchBasicSettingsPlan:
warnings = []
try:
campaigns = fetch_search_campaigns(client_config)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac kampanii Search z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii Search albo nie udalo sie ich pobrac.")
changes = []
for campaign in campaigns:
changes.extend(build_required_changes(campaign))
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Uzyj `python gads.py wiedza przypisz --restart`, gdy bedziemy wybierac reguly dla Search."
)
return SearchBasicSettingsPlan(
campaigns=campaigns,
changes=changes,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_search_basic_settings_plan(domain: str, plan: SearchBasicSettingsPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie podstawowych ustawien Search",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie Search: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
f"- Zmiany do wdrozenia: {len(plan.changes)}",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie Search",
"",
"| Kampania | Status | Lokalizacje | Google Search | Search Network | Partnerzy | Siec reklamowa |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {md_cell(campaign['campaign_name'])} | {campaign['status']} | "
f"{campaign.get('positive_geo_target_type_label', campaign['positive_geo_target_type'])} | "
f"{yes_no(campaign['target_google_search'])} | "
f"{yes_no(campaign['target_search_network'])} | "
f"{yes_no(campaign['target_partner_search_network'])} | "
f"{yes_no(campaign['target_content_network'])} |"
)
lines.append("")
if plan.changes:
lines.extend(
[
"## Planowane korekty",
"",
"| Kampania | Ustawienie | Obecnie | Docelowo |",
"| --- | --- | --- | --- |",
]
)
for change in plan.changes:
lines.append(
f"| {md_cell(change['campaign_name'])} | {md_cell(change['setting'])} | "
f"{md_cell(change.get('current_label', change['current_value']))} | "
f"{md_cell(change.get('target_label', change['target_value']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_search_basic_settings_plan(plan: SearchBasicSettingsPlan) -> None:
print("\nPlan sprawdzenia podstawowych ustawien Search")
print_table(
["Metryka", "Liczba"],
[
["Kampanie Search", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", str(len(plan.changes))],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.campaigns:
print("\nKampanie Search")
print_table(
["Nr", "Kampania", "Status", "Lokalizacje", "Partnerzy", "Siec reklamowa"],
[
[
str(index),
campaign["campaign_name"],
campaign["status"],
campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]),
yes_no(campaign["target_partner_search_network"]),
yes_no(campaign["target_content_network"]),
]
for index, campaign in enumerate(plan.campaigns, 1)
],
)
if plan.changes:
print("\nPlanowane korekty")
print_table(
["Nr", "Kampania", "Ustawienie", "Obecnie", "Docelowo"],
[
[
str(index),
change["campaign_name"],
change["setting"],
change.get("current_label", change["current_value"]),
change.get("target_label", change["target_value"]),
]
for index, change in enumerate(plan.changes[:30], 1)
],
)
if len(plan.changes) > 30:
print(f"... oraz {len(plan.changes) - 30} kolejnych korekt")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_search_basic_settings_plan(
client_config: ClientConfig,
plan: SearchBasicSettingsPlan,
show_navigation: bool = True,
) -> None:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
service = google_client.get_service("CampaignService")
changes_by_campaign: dict[str, dict] = {}
for change in plan.changes:
changes_by_campaign.setdefault(
change["campaign_id"],
{"campaign_name": change["campaign_name"], "settings": set()},
)
changes_by_campaign[change["campaign_id"]]["settings"].add(change["setting"])
operations = []
for campaign_id, row in changes_by_campaign.items():
op = google_client.get_type("CampaignOperation")
campaign = op.update
campaign.resource_name = service.campaign_path(customer_id, campaign_id)
paths = []
if "lokalizacje" in row["settings"]:
campaign.geo_target_type_setting.positive_geo_target_type = (
google_client.enums.PositiveGeoTargetTypeEnum.PRESENCE
)
paths.append("geo_target_type_setting.positive_geo_target_type")
if "siec reklamowa" in row["settings"]:
campaign.network_settings.target_content_network = False
paths.append("network_settings.target_content_network")
if "partnerzy wyszukiwania" in row["settings"]:
campaign.network_settings.target_partner_search_network = False
paths.append("network_settings.target_partner_search_network")
op.update_mask = field_mask_pb2.FieldMask(paths=paths)
operations.append(op)
changed = 0
if operations:
response = service.mutate_campaigns(customer_id=customer_id, operations=operations)
changed = len(response.results)
print("\nWynik wdrozenia zmian")
print(f"Zmieniono kampanii: {changed}")
print(f"Korekty ustawien: {len(plan.changes)}")
rows = [
{
"klient": client_config.domain,
"kampania": change["campaign_name"],
"czynnosc": change["description"],
"grupa reklam": "",
"produkt": f"{change.get('current_label', change['current_value'])} -> {change.get('target_label', change['target_value'])}",
}
for change in plan.changes
]
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "wdrozono zmiany",
"campaign": ", ".join(sorted({change["campaign_name"] for change in plan.changes})[:10]),
"summary": {
"campaigns_changed": changed,
"knowledge_rules": len(plan.knowledge_rules),
"changes": len(plan.changes),
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_search_basic_settings(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str |

View File

@@ -0,0 +1,458 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_search_terms"
TASK_NAME = "Analiza zapytan i wykluczen"
MAX_SEARCH_TERMS = 100
SCOPE = [
{
"area": "Zapytania 7 dni",
"check": "Pokaz zapytania uzytkownikow z ostatnich 7 dni posortowane po koszcie.",
},
{
"area": "Kandydaci do wykluczen",
"check": "Oznacz zapytania z kliknieciami i kosztem bez konwersji jako material do recznej oceny.",
},
{
"area": "Broad match i jakosc dopasowan",
"check": "Zbierz kontekst kampanii i grup reklam, aby latwiej ocenic, czy ruch pasuje do intencji.",
},
{
"area": "Brand / non-brand",
"check": "Nie rozstrzygaj automatycznie brandu; pokaz zapytania tak, aby agent mogl ocenic intencje.",
},
]
OUT_OF_SCOPE = [
"budzety i pacing budzetu",
"strategie stawek oraz cele Docelowy ROAS/Docelowy CPA",
"podstawowe ustawienia kampanii, np. lokalizacje i sieci",
"automatyczne dodawanie wykluczen do konta",
"ocena reklam RSA i zasobow reklam",
]
@dataclass
class SearchTermsPlan:
currency_code: str
search_terms: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"search_terms": self.search_terms,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "SearchTermsPlan":
return cls(
currency_code=data.get("currency_code", ""),
search_terms=data.get("search_terms", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def format_decimal(value: int | float) -> str:
return f"{float(value or 0):.2f}"
def safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def safe_float(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def action_label(clicks: int, cost_micros: int, conversions: float) -> str:
if clicks <= 0:
return "brak klikniec"
if conversions > 0:
return "zostaw do oceny pozytywnej"
if clicks >= 10:
return "pilny kandydat do oceny"
if clicks >= 5:
return "kandydat do oceny"
if cost_micros > 0:
return "obserwuj"
return "brak kosztu"
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def fetch_search_terms(client_config: ClientConfig) -> tuple[str, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
rows = run_query(
google_client,
customer_id,
f"""
SELECT
campaign.id,
campaign.name,
campaign.advertising_channel_type,
ad_group.id,
ad_group.name,
search_term_view.search_term,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM search_term_view
WHERE campaign.status = 'ENABLED'
AND ad_group.status = 'ENABLED'
AND campaign.advertising_channel_type = 'SEARCH'
AND segments.date DURING LAST_7_DAYS
ORDER BY metrics.cost_micros DESC
LIMIT {MAX_SEARCH_TERMS}
""",
)
search_terms = []
for row in rows:
clicks = safe_int(row.metrics.clicks)
cost_micros = safe_int(row.metrics.cost_micros)
conversions = safe_float(row.metrics.conversions)
search_terms.append(
{
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"channel_type": enum_name(row.campaign.advertising_channel_type),
"ad_group_id": str(row.ad_group.id),
"ad_group_name": row.ad_group.name,
"search_term": str(row.search_term_view.search_term or ""),
"impressions": safe_int(row.metrics.impressions),
"clicks": clicks,
"cost_micros": cost_micros,
"conversions": conversions,
"conversion_value": safe_float(row.metrics.conversions_value),
"action_label": action_label(clicks, cost_micros, conversions),
}
)
return currency_code, search_terms
def build_search_terms_plan(client_config: ClientConfig) -> SearchTermsPlan:
warnings = []
try:
currency_code, search_terms = fetch_search_terms(client_config)
except Exception as exc:
currency_code = ""
search_terms = []
warnings.append(f"Nie udalo sie pobrac zapytan z Google Ads API: {exc}")
if not search_terms:
warnings.append("Nie znaleziono zapytan Search z ostatnich 7 dni albo nie udalo sie ich pobrac.")
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace zapytan i wykluczen bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return SearchTermsPlan(
currency_code=currency_code,
search_terms=search_terms,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_search_terms_plan(domain: str, plan: SearchTermsPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Analiza zapytan i wykluczen",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Zapytania z ostatnich 7 dni: {len(plan.search_terms)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.search_terms:
lines.extend(
[
"## Zapytania z ostatnich 7 dni",
"",
"| Zapytanie | Kampania | Grupa reklam | Klikniecia | Koszt | Konwersje | Ocena |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
)
for term in plan.search_terms:
lines.append(
f"| {term['search_term']} | {term['campaign_name']} | {term['ad_group_name']} | "
f"{term['clicks']} | {format_money(term['cost_micros'], plan.currency_code)} | "
f"{format_decimal(term['conversions'])} | {term['action_label']} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_search_terms_plan(plan: SearchTermsPlan) -> None:
print("\nPlan analizy zapytan i wykluczen")
print_table(
["Metryka", "Liczba"],
[
["Zapytania z 7 dni", str(len(plan.search_terms))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.search_terms:
print("\nZapytania z ostatnich 7 dni")
print_table(
["Nr", "Zapytanie", "Kampania", "Klik.", "Koszt", "Konw.", "Ocena"],
[
[
str(index),
term["search_term"],
term["campaign_name"],
str(term["clicks"]),
format_money(term["cost_micros"], plan.currency_code),
format_decimal(term["conversions"]),
term["action_label"],
]
for index, term in enumerate(plan.search_terms[:30], 1)
],
)
if len(plan.search_terms) > 30:
print(f"... oraz {len(plan.search_terms) - 30} kolejnych zapytan w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_search_terms_plan(
client_config: ClientConfig,
plan: SearchTermsPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem zapytan i nie dodaje wykluczen na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(sorted({term["campaign_name"] for term in plan.search_terms})[:10]),
"summary": {
"search_terms": len(plan.search_terms),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_search_terms(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = SearchTermsPlan.from_dict(plan_data)
print_search_terms_plan(plan)
apply_search_terms_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan analizy zapytan i wykluczen...")
plan = build_search_terms_plan(client_config)
print_search_terms_plan(plan)
json_path, md_path = save_search_terms_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(sorted({term["campaign_name"] for term in plan.search_terms})[:10]),
"summary": {
"search_terms": len(plan.search_terms),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu zapytan.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,637 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_shopping_product_statuses"
TASK_NAME = "Sprawdzenie statusow produktow Shopping"
DEFAULT_PRODUCT_LIMIT = 500
SCOPE = [
{
"area": "Status produktu",
"check": "Pobierz statusy produktow z zasobu shopping_product w Google Ads API.",
},
{
"area": "Emisja 30 dni",
"check": "Porownaj produkty ze statusem z danymi emisji z shopping_performance_view z ostatnich 30 dni.",
},
{
"area": "Produkty do oceny",
"check": "Oznacz produkty niekwalifikujace sie albo kwalifikujace sie, ale bez wyswietlen w ostatnich 30 dniach.",
},
{
"area": "Granica zadania",
"check": "Oddziel status i emisje produktu od optymalizacji tytulow, kategorii Google, unit pricing i napraw feedu.",
},
]
OUT_OF_SCOPE = [
"optymalizacja tytulow produktow",
"wybor kategorii Google",
"uzupelnianie unit pricing",
"naprawa feedu w adsPRO albo Merchant Center",
"zmiany stawek, budzetow i struktury kampanii Shopping/PMax",
]
PROBLEM_PRODUCT_STATUSES = {
"DISAPPROVED",
"NOT_ELIGIBLE",
"LIMITED",
"UNKNOWN",
"UNSPECIFIED",
}
@dataclass
class ShoppingProductStatusPlan:
product_limit: int
products: list[dict]
status_summary: list[dict]
issue_summary: list[dict]
problem_items: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"product_limit": self.product_limit,
"products": self.products,
"status_summary": self.status_summary,
"issue_summary": self.issue_summary,
"problem_items": self.problem_items,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "ShoppingProductStatusPlan":
return cls(
product_limit=int(data.get("product_limit") or DEFAULT_PRODUCT_LIMIT),
products=data.get("products", []),
status_summary=data.get("status_summary", []),
issue_summary=data.get("issue_summary", []),
problem_items=data.get("problem_items", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def md_cell(value: Any) -> str:
return str(value or "").replace("|", "\\|").replace("\n", " ").strip()
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def issue_to_dict(issue: Any) -> dict:
code = getattr(issue, "code", "")
severity = getattr(issue, "severity", "")
resolution = getattr(issue, "resolution", "")
attribute = getattr(issue, "attribute", "")
description = getattr(issue, "description", "")
detail = getattr(issue, "detail", "")
return {
"code": enum_name(code),
"severity": enum_name(severity),
"resolution": enum_name(resolution),
"attribute": str(attribute or ""),
"description": str(description or ""),
"detail": str(detail or ""),
}
def fetch_shopping_products(client_config: ClientConfig, limit: int) -> tuple[str, list[dict]]:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
rows = run_query(
google_client,
customer_id,
f"""
SELECT
shopping_product.merchant_center_id,
shopping_product.channel,
shopping_product.language_code,
shopping_product.feed_label,
shopping_product.item_id,
shopping_product.title,
shopping_product.status,
shopping_product.issues
FROM shopping_product
LIMIT {int(limit)}
""",
)
products = []
for row in rows:
product = row.shopping_product
issues = [issue_to_dict(issue) for issue in getattr(product, "issues", [])]
products.append(
{
"merchant_center_id": str(product.merchant_center_id or ""),
"channel": enum_name(product.channel),
"language_code": str(product.language_code or ""),
"feed_label": str(product.feed_label or ""),
"item_id": str(product.item_id or ""),
"title": str(product.title or ""),
"status": enum_name(product.status),
"issues": issues,
"issue_count": len(issues),
"impressions_30d": 0,
"clicks_30d": 0,
"cost_30d_micros": 0,
"conversions_30d": 0.0,
"conversion_value_30d": 0.0,
}
)
return currency_code, products
def fetch_product_performance_30d(client_config: ClientConfig) -> dict[str, dict]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT
segments.product_item_id,
segments.product_title,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date DURING LAST_30_DAYS
LIMIT 10000
""",
)
performance: dict[str, dict] = {}
for row in rows:
item_id = str(row.segments.product_item_id or "")
if not item_id:
continue
record = performance.setdefault(
item_id,
{
"item_id": item_id,
"title": str(row.segments.product_title or ""),
"impressions_30d": 0,
"clicks_30d": 0,
"cost_30d_micros": 0,
"conversions_30d": 0.0,
"conversion_value_30d": 0.0,
},
)
record["impressions_30d"] += int(row.metrics.impressions or 0)
record["clicks_30d"] += int(row.metrics.clicks or 0)
record["cost_30d_micros"] += int(row.metrics.cost_micros or 0)
record["conversions_30d"] += float(row.metrics.conversions or 0)
record["conversion_value_30d"] += float(row.metrics.conversions_value or 0)
return performance
def product_severity(product: dict) -> str:
if product["status"] in {"DISAPPROVED", "NOT_ELIGIBLE"}:
return "wysokie"
if product["status"] in {"LIMITED", "UNKNOWN", "UNSPECIFIED"}:
return "srednie"
if product["issue_count"] > 0:
return "srednie"
if product["status"] == "ELIGIBLE" and product["impressions_30d"] == 0:
return "niskie"
return "ok"
def product_flags(product: dict) -> list[str]:
flags = []
if product["status"] in PROBLEM_PRODUCT_STATUSES:
flags.append(f"status: {product['status']}")
if product["issue_count"]:
flags.append(f"problemy MC/API: {product['issue_count']}")
if product["status"] == "ELIGIBLE" and product["impressions_30d"] == 0:
flags.append("brak wyswietlen 30 dni")
return flags or ["ok"]
def attach_performance(products: list[dict], performance: dict[str, dict]) -> list[dict]:
for product in products:
perf = performance.get(product["item_id"], {})
product["impressions_30d"] = int(perf.get("impressions_30d", 0))
product["clicks_30d"] = int(perf.get("clicks_30d", 0))
product["cost_30d_micros"] = int(perf.get("cost_30d_micros", 0))
product["conversions_30d"] = float(perf.get("conversions_30d", 0.0))
product["conversion_value_30d"] = float(perf.get("conversion_value_30d", 0.0))
product["severity"] = product_severity(product)
product["flags"] = product_flags(product)
severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}
products.sort(
key=lambda row: (
severity_order.get(row["severity"], 9),
-row["impressions_30d"],
row["title"],
)
)
return products
def build_status_summary(products: list[dict]) -> list[dict]:
counter = Counter(product["status"] for product in products)
return [{"status": key, "count": value} for key, value in counter.most_common()]
def build_issue_summary(products: list[dict]) -> list[dict]:
counter: Counter[str] = Counter()
for product in products:
for issue in product.get("issues", []):
code = issue.get("code") or "(brak kodu)"
counter[code] += 1
return [{"issue_code": key, "count": value} for key, value in counter.most_common()]
def build_problem_items(products: list[dict], currency_code: str) -> list[dict]:
items = []
for product in products:
if product["flags"] == ["ok"]:
continue
items.append(
{
"severity": product["severity"],
"item_id": product["item_id"],
"title": product["title"],
"status": product["status"],
"impressions_30d": product["impressions_30d"],
"clicks_30d": product["clicks_30d"],
"cost_30d": format_money(product["cost_30d_micros"], currency_code),
"flags": product["flags"],
"recommendation": "sprawdz status produktu w Merchant Center albo w diagnostyce produktow Google Ads",
}
)
return items
def build_shopping_product_status_plan(
client_config: ClientConfig,
global_rules: dict,
) -> ShoppingProductStatusPlan:
rules = client_config.effective_rules(global_rules, "shopping_product_statuses")
limit = int(rules.get("limit", DEFAULT_PRODUCT_LIMIT))
warnings = []
currency_code = ""
try:
currency_code, products = fetch_shopping_products(client_config, limit)
except Exception as exc:
products = []
warnings.append(f"Nie udalo sie pobrac statusow shopping_product z Google Ads API: {exc}")
try:
performance = fetch_product_performance_30d(client_config)
except Exception as exc:
performance = {}
warnings.append(f"Nie udalo sie pobrac emisji produktow z shopping_performance_view: {exc}")
products = attach_performance(products, performance)
if not products:
warnings.append("Nie znaleziono produktow Shopping albo nie udalo sie ich pobrac.")
if len(products) >= limit:
warnings.append(f"Pobrano pierwsze {limit} produktow z Google Ads API; zwieksz limit w regulach klienta, jezeli trzeba szerszego audytu.")
warnings.append(
"To zadanie sprawdza status i emisje produktu. Naprawy feedu, tytulow, kategorii Google i unit pricing pozostaja w osobnych zadaniach."
)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules_for_task(TASK_ID)
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Reguly dotyczace statusow produktow Shopping bedziemy dopisywac osobno po akceptacji uzytkownika."
)
return ShoppingProductStatusPlan(
product_limit=limit,
products=products,
status_summary=build_status_summary(products),
issue_summary=build_issue_summary(products),
problem_items=build_problem_items(products, currency_code),
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_shopping_product_status_plan(domain: str, plan: ShoppingProductStatusPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie statusow produktow Shopping",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Produkty sprawdzone: {len(plan.products)}",
f"- Elementy do oceny: {len(plan.problem_items)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {md_cell(row.get('area', ''))} | {md_cell(row.get('check', ''))} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.status_summary:
lines.extend(["## Statusy produktow", "", "| Status | Liczba |", "| --- | --- |"])
for row in plan.status_summary:
lines.append(f"| {row['status']} | {row['count']} |")
lines.append("")
if plan.issue_summary:
lines.extend(["## Problemy produktow", "", "| Kod problemu | Liczba |", "| --- | --- |"])
for row in plan.issue_summary:
lines.append(f"| {md_cell(row['issue_code'])} | {row['count']} |")
lines.append("")
if plan.problem_items:
lines.extend(
[
"## Elementy do oceny",
"",
"| Waznosc | Produkt | Tytul | Status | Wyswietlenia 30d | Klikniecia 30d | Koszt 30d | Flagi | Rekomendacja |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for item in plan.problem_items:
lines.append(
f"| {item['severity']} | {md_cell(item['item_id'])} | {md_cell(item['title'])} | {item['status']} | "
f"{item['impressions_30d']} | {item['clicks_30d']} | {item['cost_30d']} | "
f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |"
)
lines.append("")
if plan.products:
lines.extend(
[
"## Produkty",
"",
"| Produkt | Tytul | Status | Jezyk | Feed label | Wyswietlenia 30d | Klikniecia 30d | Flagi |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for product in plan.products:
lines.append(
f"| {md_cell(product['item_id'])} | {md_cell(product['title'])} | {product['status']} | "
f"{product['language_code']} | {md_cell(product['feed_label'])} | {product['impressions_30d']} | "
f"{product['clicks_30d']} | {md_cell(', '.join(product['flags']))} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | "
f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_shopping_product_status_plan(plan: ShoppingProductStatusPlan) -> None:
print("\nPlan sprawdzenia statusow produktow Shopping")
print_table(
["Metryka", "Liczba"],
[
["Produkty sprawdzone", str(len(plan.products))],
["Elementy do oceny", str(len(plan.problem_items))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.status_summary:
print("\nStatusy produktow")
print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.status_summary])
if plan.issue_summary:
print("\nProblemy produktow")
print_table(["Kod problemu", "Liczba"], [[row["issue_code"], str(row["count"])] for row in plan.issue_summary])
if plan.problem_items:
print("\nElementy do oceny")
print_table(
["Nr", "Waznosc", "Produkt", "Status", "Wysw. 30d", "Klik. 30d", "Flagi"],
[
[
str(index),
item["severity"],
item["item_id"],
item["status"],
str(item["impressions_30d"]),
str(item["clicks_30d"]),
", ".join(item["flags"]),
]
for index, item in enumerate(plan.problem_items[:30], 1)
],
)
if len(plan.problem_items) > 30:
print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu")
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_shopping_product_status_plan(
client_config: ClientConfig,
plan: ShoppingProductStatusPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem statusow produktow i nie wdraza zmian w Google Ads, adsPRO ani Merchant Center.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"product": ", ".join(item["item_id"] for item in plan.problem_items[:10]),
"summary": {
"products": len(plan.products),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_shopping_product_statuses(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = ShoppingProductStatusPlan.from_dict(plan_data)
print_shopping_product_status_plan(plan)
apply_shopping_product_status_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia statusow produktow Shopping...")
plan = build_shopping_product_status_plan(client_config, global_rules)
print_shopping_product_status_plan(plan)
json_path, md_path = save_shopping_product_status_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"product": ", ".join(item["item_id"] for item in plan.problem_items[:10]),
"summary": {
"products": len(plan.products),
"problem_items": len(plan.problem_items),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu statusow produktow Shopping.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -0,0 +1,788 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from google.protobuf import field_mask_pb2
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "optimize_shopping_troas_ag"
TASK_NAME = "Automatyzacja tROAS per grupa reklam PLA"
MIN_CLICKS_ALL_TIME = 100
CONVERSIONS_TRIGGER = 10
MIN_ROAS_DELTA = 1.0
MAX_TROAS_STEP = 0.5
SCOPE = [
{
"area": "Zakres",
"check": "Analizuje tylko aktywne grupy reklam w aktywnych kampaniach Standard Shopping.",
},
{
"area": "Dane 30 dni",
"check": "Liczy realny ROAS grupy reklam z kosztu i wartosci konwersji z ostatnich 30 dni.",
},
{
"area": "100 klikow",
"check": "Grupy reklam z mniej niz 100 klikami od poczatku trafiaja tylko na watchliste.",
},
{
"area": "Trigger 10 konwersji",
"check": "Podbicie tROAS wymaga co najmniej 10 nowych konwersji wzgledem lokalnego baseline.",
},
{
"area": "Stopniowanie",
"check": "Jedna analiza moze podniesc tROAS grupy reklam maksymalnie o 0.5.",
},
{
"area": "Rollback",
"check": "Jesli po zmianie realny ROAS z 30 dni spada ponizej ustawionego tROAS, plan proponuje przywrocenie poprzedniej wartosci.",
},
]
OUT_OF_SCOPE = [
"Performance Max",
"Search",
"kampanie Shopping bez grup reklam",
"automatyczne wdrozenie bez akceptacji uzytkownika",
"pauzowanie grup reklam z niskim albo zerowym ROAS",
]
@dataclass
class ShoppingTroasAgPlan:
currency_code: str
ad_groups: list[dict]
watchlist: list[dict]
target_changes: list[dict]
rollback_changes: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
changes = self.rollback_changes + self.target_changes
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"currency_code": self.currency_code,
"ad_groups": self.ad_groups,
"watchlist": self.watchlist,
"target_changes": self.target_changes,
"rollback_changes": self.rollback_changes,
"changes": changes,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
}
@classmethod
def from_dict(cls, data: dict) -> "ShoppingTroasAgPlan":
return cls(
currency_code=data.get("currency_code", ""),
ad_groups=data.get("ad_groups", []),
watchlist=data.get("watchlist", []),
target_changes=data.get("target_changes", []),
rollback_changes=data.get("rollback_changes", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value: Any) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def safe_float(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def micros_to_amount(value: int | float) -> float:
return round(float(value or 0) / 1_000_000, 2)
def format_money(value: int | float, currency_code: str) -> str:
suffix = f" {currency_code}" if currency_code else ""
return f"{micros_to_amount(value):.2f}{suffix}"
def format_decimal(value: int | float) -> str:
return f"{float(value or 0):.2f}"
def troas_label(value: int | float) -> str:
if not value:
return "brak override"
return f"{float(value):.2f}"
def baseline_path(domain: str) -> Path:
return client_dir(domain) / "troas_ag_baseline.json"
def load_baseline(domain: str) -> dict:
path = baseline_path(domain)
if not path.exists():
return {"ad_groups": {}}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"ad_groups": {}}
if not isinstance(data, dict):
return {"ad_groups": {}}
data.setdefault("ad_groups", {})
return data
def save_baseline(domain: str, data: dict) -> Path:
path = baseline_path(domain)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
return path
def real_roas(cost_micros: int, conversion_value: float) -> float:
cost = micros_to_amount(cost_micros)
if cost <= 0:
return 0.0
return round(float(conversion_value or 0) / cost, 2)
def effective_troas(row: dict) -> float:
ad_group_target = safe_float(row.get("ad_group_target_roas"))
if ad_group_target > 0:
return ad_group_target
return safe_float(row.get("campaign_target_roas")) or safe_float(row.get("campaign_maximize_target_roas"))
def current_target_source(row: dict) -> str:
if safe_float(row.get("ad_group_target_roas")) > 0:
return "grupa reklam"
if safe_float(row.get("campaign_target_roas")) > 0 or safe_float(row.get("campaign_maximize_target_roas")) > 0:
return "kampania"
return "brak celu"
def suggested_raise(row: dict) -> dict | None:
if safe_int(row.get("clicks_all_time")) < MIN_CLICKS_ALL_TIME:
return None
conversions_delta = safe_float(row.get("new_conversions_since_baseline"))
if conversions_delta < CONVERSIONS_TRIGGER:
return None
current = safe_float(row.get("troas_pre"))
actual = safe_float(row.get("real_roas_30d"))
if current <= 0:
return None
if actual - current <= MIN_ROAS_DELTA:
return None
target = min(round(actual - 0.5, 2), round(current + MAX_TROAS_STEP, 2))
if target <= current:
return None
return {
"change_type": "raise",
"campaign_id": row["campaign_id"],
"campaign_name": row["campaign_name"],
"ad_group_id": row["ad_group_id"],
"ad_group_name": row["ad_group_name"],
"current_troas": current,
"target_troas": target,
"previous_troas": current,
"real_roas_30d": actual,
"delta": round(actual - current, 2),
"new_conversions_since_baseline": conversions_delta,
"clicks_all_time": row.get("clicks_all_time", 0),
"reason": "realny ROAS z 30 dni jest wyzszy od tROAS o ponad 1.0 i minelo co najmniej 10 nowych konwersji od baseline",
}
def suggested_rollback(row: dict) -> dict | None:
baseline = row.get("baseline") or {}
last_target = safe_float(baseline.get("last_target_troas"))
previous = safe_float(baseline.get("previous_troas"))
actual = safe_float(row.get("real_roas_30d"))
if last_target <= 0 or previous <= 0:
return None
if actual >= last_target:
return None
return {
"change_type": "rollback",
"campaign_id": row["campaign_id"],
"campaign_name": row["campaign_name"],
"ad_group_id": row["ad_group_id"],
"ad_group_name": row["ad_group_name"],
"current_troas": row["troas_pre"],
"target_troas": previous,
"previous_troas": previous,
"real_roas_30d": actual,
"delta": round(actual - last_target, 2),
"new_conversions_since_baseline": row.get("new_conversions_since_baseline", 0),
"clicks_all_time": row.get("clicks_all_time", 0),
"reason": "realny ROAS po zmianie jest nizszy niz ustawiony tROAS; plan proponuje przywrocenie poprzedniej wartosci",
}
def watchlist_reason(row: dict) -> str:
clicks = safe_int(row.get("clicks_all_time"))
actual = safe_float(row.get("real_roas_30d"))
conversions = safe_float(row.get("conversions_30d"))
if clicks < MIN_CLICKS_ALL_TIME and (actual <= 0 or conversions <= 0):
return "mniej niz 100 klikow od poczatku i niski albo zerowy ROAS - obserwuj, bez akcji"
if clicks < MIN_CLICKS_ALL_TIME:
return "mniej niz 100 klikow od poczatku - obserwuj, bez akcji"
return ""
def fetch_currency_code(google_client, customer_id: str) -> str:
rows = run_query(
google_client,
customer_id,
"""
SELECT
customer.currency_code
FROM customer
""",
)
if not rows:
return ""
return str(rows[0].customer.currency_code or "")
def fetch_all_time_clicks(google_client, customer_id: str) -> dict[str, int]:
rows = run_query(
google_client,
customer_id,
"""
SELECT
ad_group.id,
metrics.clicks
FROM ad_group
WHERE campaign.status = 'ENABLED'
AND ad_group.status = 'ENABLED'
AND campaign.advertising_channel_type = 'SHOPPING'
""",
)
clicks: dict[str, int] = {}
for row in rows:
clicks[str(row.ad_group.id)] = safe_int(row.metrics.clicks)
return clicks
def fetch_shopping_ad_groups(client_config: ClientConfig) -> tuple[str, list[dict], list[str]]:
warnings: list[str] = []
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
currency_code = fetch_currency_code(google_client, customer_id)
all_time_clicks = fetch_all_time_clicks(google_client, customer_id)
rows = run_query(
google_client,
customer_id,
"""
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.target_roas.target_roas,
campaign.maximize_conversion_value.target_roas,
ad_group.id,
ad_group.name,
ad_group.status,
ad_group.type,
ad_group.target_roas,
ad_group.effective_target_roas,
ad_group.effective_target_roas_source,
metrics.cost_micros,
metrics.clicks,
metrics.conversions,
metrics.conversions_value
FROM ad_group
WHERE campaign.status = 'ENABLED'
AND ad_group.status = 'ENABLED'
AND campaign.advertising_channel_type = 'SHOPPING'
AND segments.date DURING LAST_30_DAYS
""",
)
records = []
for row in rows:
ad_group = row.ad_group
campaign = row.campaign
ad_group_id = str(ad_group.id)
cost_micros = safe_int(row.metrics.cost_micros)
conversion_value = safe_float(row.metrics.conversions_value)
record = {
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"campaign_status": enum_name(campaign.status),
"channel_type": enum_name(campaign.advertising_channel_type),
"campaign_target_roas": safe_float(campaign.target_roas.target_roas),
"campaign_maximize_target_roas": safe_float(campaign.maximize_conversion_value.target_roas),
"ad_group_id": ad_group_id,
"ad_group_name": ad_group.name,
"ad_group_status": enum_name(ad_group.status),
"ad_group_type": enum_name(ad_group.type),
"ad_group_target_roas": safe_float(ad_group.target_roas),
"ad_group_effective_target_roas": safe_float(ad_group.effective_target_roas),
"ad_group_effective_target_roas_source": enum_name(ad_group.effective_target_roas_source),
"cost_30d_micros": cost_micros,
"clicks_30d": safe_int(row.metrics.clicks),
"clicks_all_time": all_time_clicks.get(ad_group_id, 0),
"conversions_30d": round(safe_float(row.metrics.conversions), 2),
"conversion_value_30d": round(conversion_value, 2),
"real_roas_30d": real_roas(cost_micros, conversion_value),
}
record["troas_pre"] = effective_troas(record)
record["troas_source"] = current_target_source(record)
records.append(record)
if not records:
warnings.append("Nie znaleziono aktywnych grup reklam w aktywnych kampaniach Standard Shopping z danymi z ostatnich 30 dni.")
return currency_code, records, warnings
def build_shopping_troas_ag_plan(client_config: ClientConfig) -> ShoppingTroasAgPlan:
warnings: list[str] = []
try:
currency_code, ad_groups, fetch_warnings = fetch_shopping_ad_groups(client_config)
warnings.extend(fetch_warnings)
except Exception as exc:
currency_code = ""
ad_groups = []
warnings.append(f"Nie udalo sie pobrac grup reklam Shopping z Google Ads API: {exc}")
baseline = load_baseline(client_config.domain)
baseline_rows = baseline.get("ad_groups", {})
watchlist: list[dict] = []
target_changes: list[dict] = []
rollback_changes: list[dict] = []
for row in ad_groups:
stored = baseline_rows.get(row["ad_group_id"], {})
row["baseline"] = stored
baseline_conversions = safe_float(stored.get("baseline_conversions_30d"))
row["baseline_conversions_30d"] = baseline_conversions
row["baseline_known"] = bool(stored)
row["new_conversions_since_baseline"] = round(max(0.0, row["conversions_30d"] - baseline_conversions), 2)
row["watchlist_reason"] = watchlist_reason(row)
if row["watchlist_reason"]:
watchlist.append(row)
continue
rollback = suggested_rollback(row)
if rollback:
rollback_changes.append(rollback)
continue
change = suggested_raise(row)
if change:
target_changes.append(change)
ad_groups.sort(key=lambda item: (item["campaign_name"], item["ad_group_name"]))
watchlist.sort(
key=lambda item: (
-safe_int(item["clicks_all_time"]),
-safe_float(item["conversions_30d"]),
item["campaign_name"],
item["ad_group_name"],
)
)
target_changes.sort(key=lambda item: (-item["delta"], item["campaign_name"], item["ad_group_name"]))
rollback_changes.sort(key=lambda item: (item["campaign_name"], item["ad_group_name"]))
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append("Do tego zadania nie przypisano jeszcze regul z bazy wiedzy.")
return ShoppingTroasAgPlan(
currency_code=currency_code,
ad_groups=ad_groups,
watchlist=watchlist,
target_changes=target_changes,
rollback_changes=rollback_changes,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_shopping_troas_ag_plan(domain: str, plan: ShoppingTroasAgPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Automatyzacja tROAS per grupa reklam PLA",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Grupy reklam Shopping z danymi 30 dni: {len(plan.ad_groups)}",
f"- Kandydaci do podniesienia tROAS: {len(plan.target_changes)}",
f"- Kandydaci do rollbacku: {len(plan.rollback_changes)}",
f"- Watchlista bez akcji: {len(plan.watchlist)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
if plan.target_changes:
lines.extend(
[
"## Kandydaci do podniesienia tROAS",
"",
"| Kampania | Grupa reklam | tROAS pre | Real ROAS | Delta | Nowy tROAS | Nowe konwersje | Klikniecia all-time |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
)
for change in plan.target_changes:
lines.append(
f"| {change['campaign_name']} | {change['ad_group_name']} | "
f"{format_decimal(change['current_troas'])} | {format_decimal(change['real_roas_30d'])} | "
f"{format_decimal(change['delta'])} | {format_decimal(change['target_troas'])} | "
f"{format_decimal(change['new_conversions_since_baseline'])} | {change['clicks_all_time']} |"
)
lines.append("")
if plan.rollback_changes:
lines.extend(
[
"## Kandydaci do rollbacku",
"",
"| Kampania | Grupa reklam | Obecny tROAS | Real ROAS | Przywroc tROAS | Powod |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for change in plan.rollback_changes:
lines.append(
f"| {change['campaign_name']} | {change['ad_group_name']} | "
f"{format_decimal(change['current_troas'])} | {format_decimal(change['real_roas_30d'])} | "
f"{format_decimal(change['target_troas'])} | {change['reason']} |"
)
lines.append("")
if plan.watchlist:
lines.extend(
[
"## Watchlista bez akcji",
"",
"| Kampania | Grupa reklam | Klikniecia all-time | Konwersje 30 dni | Real ROAS | Powod |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for row in plan.watchlist:
lines.append(
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['clicks_all_time']} | "
f"{format_decimal(row['conversions_30d'])} | {format_decimal(row['real_roas_30d'])} | "
f"{row['watchlist_reason']} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_shopping_troas_ag_plan(plan: ShoppingTroasAgPlan) -> None:
print("\nPlan automatyzacji tROAS per grupa reklam PLA")
print_table(
["Metryka", "Liczba"],
[
["Grupy reklam Shopping z danymi 30 dni", str(len(plan.ad_groups))],
["Kandydaci do podniesienia tROAS", str(len(plan.target_changes))],
["Kandydaci do rollbacku", str(len(plan.rollback_changes))],
["Watchlista bez akcji", str(len(plan.watchlist))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
if plan.target_changes:
print("\nKandydaci do podniesienia tROAS")
print_table(
["Nr", "Kampania", "Grupa reklam", "tROAS pre", "Real ROAS", "Delta", "Nowy tROAS", "Nowe konw."],
[
[
str(index),
change["campaign_name"],
change["ad_group_name"],
format_decimal(change["current_troas"]),
format_decimal(change["real_roas_30d"]),
format_decimal(change["delta"]),
format_decimal(change["target_troas"]),
format_decimal(change["new_conversions_since_baseline"]),
]
for index, change in enumerate(plan.target_changes, 1)
],
)
if plan.rollback_changes:
print("\nKandydaci do rollbacku")
print_table(
["Nr", "Kampania", "Grupa reklam", "Obecny tROAS", "Real ROAS", "Przywroc", "Powod"],
[
[
str(index),
change["campaign_name"],
change["ad_group_name"],
format_decimal(change["current_troas"]),
format_decimal(change["real_roas_30d"]),
format_decimal(change["target_troas"]),
change["reason"],
]
for index, change in enumerate(plan.rollback_changes, 1)
],
)
if plan.watchlist:
print("\nWatchlista bez akcji")
print_table(
["Nr", "Kampania", "Grupa reklam", "Klikniecia", "Konw. 30d", "ROAS", "Powod"],
[
[
str(index),
row["campaign_name"],
row["ad_group_name"],
str(row["clicks_all_time"]),
format_decimal(row["conversions_30d"]),
format_decimal(row["real_roas_30d"]),
row["watchlist_reason"],
]
for index, row in enumerate(plan.watchlist, 1)
],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
def apply_troas_changes(client_config: ClientConfig, plan: ShoppingTroasAgPlan, show_navigation: bool = True) -> None:
changes = plan.rollback_changes + plan.target_changes
changed = 0
errors: list[str] = []
if changes:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
service = google_client.get_service("AdGroupService")
operations = []
for change in changes:
op = google_client.get_type("AdGroupOperation")
ad_group = op.update
ad_group.resource_name = service.ad_group_path(customer_id, change["ad_group_id"])
ad_group.target_roas = float(change["target_troas"])
op.update_mask = field_mask_pb2.FieldMask(paths=["target_roas"])
operations.append(op)
try:
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
changed = len(response.results)
except Exception as exc:
errors.append(str(exc))
print("\nWynik wdrozenia zmian tROAS grup reklam")
print(f"Zmieniono grup reklam: {changed}")
print(f"Bledy: {len(errors)}")
for error in errors:
print(f"Blad: {error}")
if changed and not errors:
baseline = load_baseline(client_config.domain)
baseline_rows = baseline.setdefault("ad_groups", {})
ts = now_local().isoformat(timespec="seconds")
ad_groups_by_id = {row["ad_group_id"]: row for row in plan.ad_groups}
for change in changes:
source = ad_groups_by_id.get(change["ad_group_id"], {})
baseline_rows[change["ad_group_id"]] = {
"updated_at": ts,
"campaign_id": change["campaign_id"],
"campaign_name": change["campaign_name"],
"ad_group_id": change["ad_group_id"],
"ad_group_name": change["ad_group_name"],
"previous_troas": change["current_troas"],
"last_target_troas": change["target_troas"],
"baseline_conversions_30d": source.get("conversions_30d", 0),
"baseline_real_roas_30d": source.get("real_roas_30d", 0),
"change_type": change["change_type"],
}
baseline_file = save_baseline(client_config.domain, baseline)
print(f"Baseline tROAS AG: {baseline_file}")
rows = [
{
"klient": client_config.domain,
"kampania": change.get("campaign_name", ""),
"grupa reklam": change.get("ad_group_name", ""),
"czynnosc": "Rollback tROAS" if change.get("change_type") == "rollback" else "Podniesienie tROAS",
"produkt": f"{format_decimal(change.get('current_troas', 0))} -> {format_decimal(change.get('target_troas', 0))}",
}
for change in changes
]
changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows)
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "wdrozono zmiany tROAS AG" if changes and not errors else "audyt oznaczony jako wykonany",
"campaign": ", ".join(change.get("campaign_name", "") for change in changes[:10]),
"summary": {
"ad_groups": len(plan.ad_groups),
"target_changes": len(plan.target_changes),
"rollback_changes": len(plan.rollback_changes),
"changed": changed,
"errors": len(errors),
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def run_optimize_shopping_troas_ag(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = ShoppingTroasAgPlan.from_dict(plan_data)
print_shopping_troas_ag_plan(plan)
apply_troas_changes(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan automatyzacji tROAS per grupa reklam PLA...")
plan = build_shopping_troas_ag_plan(client_config)
print_shopping_troas_ag_plan(plan)
json_path, md_path = save_shopping_troas_ag_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(change["campaign_name"] for change in (plan.rollback_changes + plan.target_changes)[:10]),
"summary": {
"ad_groups": len(plan.ad_groups),
"target_changes": len(plan.target_changes),
"rollback_changes": len(plan.rollback_changes),
"watchlist": len(plan.watchlist),
"knowledge_rules": len(plan.knowledge_rules),
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak automatycznego wdrozenia. Uzyj zapisanego planu i potwierdzenia, aby wdrozyc zmiany tROAS.")
if show_navigation:
print_next_navigation(client_config.domain)