ltcai 11.1.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -53
- package/docs/CHANGELOG.md +33 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +175 -27
- package/lattice_brain/multimodal.py +525 -5
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +55 -2
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +6 -3
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +143 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +28 -0
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +26 -1
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +13 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
- package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
- package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
- package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
- package/static/app/assets/brain-uMb_5hnO.js +0 -1
- package/static/app/assets/search-DkhnOKZt.js +0 -1
|
@@ -55,6 +55,13 @@ _SIGNATURE_SIMILARITY = 0.6
|
|
|
55
55
|
_MIN_SUGGESTION_CONFIDENCE = 0.35
|
|
56
56
|
_LOW_CONFIDENCE_THRESHOLD = 0.5
|
|
57
57
|
_KG_GROUNDING_LIMIT = 5
|
|
58
|
+
#: Charged when the graph *was* consulted and found nothing related. v11.2.0
|
|
59
|
+
#: fixes a dead policy: the floor above was unreachable for recurring questions
|
|
60
|
+
#: (the cheapest possible question — asked twice, one phrasing, no recipe —
|
|
61
|
+
#: scored 0.475), so the suppression branch could only ever be exercised by a
|
|
62
|
+
#: test that moved the constant. Absence of grounding is real evidence, and
|
|
63
|
+
#: charging for it is what makes the floor mean something again.
|
|
64
|
+
_UNGROUNDED_PENALTY = 0.15
|
|
58
65
|
|
|
59
66
|
|
|
60
67
|
def _question_confidence(
|
|
@@ -67,7 +74,13 @@ def _question_confidence(
|
|
|
67
74
|
|
|
68
75
|
Evidence factors: how often the question repeats, how many distinct
|
|
69
76
|
phrasings exist, whether it maps onto a known starter-recipe intent, and
|
|
70
|
-
|
|
77
|
+
what the Brain knows about it.
|
|
78
|
+
|
|
79
|
+
That last factor is three-valued on purpose. ``None`` means the graph could
|
|
80
|
+
not be consulted — unknown, so nothing is added or taken away. A positive
|
|
81
|
+
count is supporting evidence. **Zero** is not the same as unknown: the
|
|
82
|
+
graph was asked, and it knows nothing about this question, which is a
|
|
83
|
+
reason to be less sure rather than a reason to be neutral.
|
|
71
84
|
"""
|
|
72
85
|
score = 0.3 + 0.5 * min(1.0, (int(count) - 1) / 4)
|
|
73
86
|
score += min(0.15, 0.05 * len(examples or []))
|
|
@@ -75,13 +88,16 @@ def _question_confidence(
|
|
|
75
88
|
score += 0.15
|
|
76
89
|
if kg_related:
|
|
77
90
|
score += min(0.2, 0.05 * int(kg_related))
|
|
91
|
+
elif kg_related == 0:
|
|
92
|
+
score -= _UNGROUNDED_PENALTY
|
|
78
93
|
factors = {
|
|
79
94
|
"repeat_count": int(count),
|
|
80
95
|
"distinct_examples": len(examples or []),
|
|
81
96
|
"intent_match": bool(recipe_id),
|
|
82
97
|
"kg_related_nodes": kg_related,
|
|
98
|
+
"kg_grounded": None if kg_related is None else bool(kg_related),
|
|
83
99
|
}
|
|
84
|
-
return round(min(1.0, score), 2), factors
|
|
100
|
+
return round(max(0.0, min(1.0, score)), 2), factors
|
|
85
101
|
|
|
86
102
|
|
|
87
103
|
def _source_confidence(indexed: int, watch_enabled: bool) -> tuple:
|
|
@@ -357,6 +373,10 @@ class AutomationIntelligenceService:
|
|
|
357
373
|
pattern["count"], pattern["examples"], recipe_id, kg_related
|
|
358
374
|
)
|
|
359
375
|
if confidence < _MIN_SUGGESTION_CONFIDENCE:
|
|
376
|
+
# Asked a couple of times, phrased one way, matching no recipe,
|
|
377
|
+
# and the Brain knows nothing about it — not enough to propose
|
|
378
|
+
# automating. Counted, so the surface can say how many were held
|
|
379
|
+
# back rather than silently showing fewer.
|
|
360
380
|
suppressed_low_confidence += 1
|
|
361
381
|
continue
|
|
362
382
|
if recipe_id:
|
|
@@ -52,6 +52,21 @@ def _parse_ts(value: Any) -> Optional[datetime]:
|
|
|
52
52
|
return parsed
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def _no_graph_reason(graph_available: bool) -> str:
|
|
56
|
+
"""Why a graph-derived health dimension has nothing to say.
|
|
57
|
+
|
|
58
|
+
Two different situations that both end in ``status: "unavailable"``: the
|
|
59
|
+
graph could not be read at all, and the graph read fine but holds nothing
|
|
60
|
+
yet. Telling them apart is the difference between "something is broken"
|
|
61
|
+
and "you have not saved anything yet".
|
|
62
|
+
"""
|
|
63
|
+
return (
|
|
64
|
+
"no knowledge saved yet"
|
|
65
|
+
if graph_available
|
|
66
|
+
else "the knowledge graph could not be read"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
55
70
|
class BrainIntelligenceService:
|
|
56
71
|
def __init__(
|
|
57
72
|
self,
|
|
@@ -395,7 +410,11 @@ class BrainIntelligenceService:
|
|
|
395
410
|
"stale_threshold_days": _STALE_DAYS,
|
|
396
411
|
}
|
|
397
412
|
else:
|
|
398
|
-
dimensions["freshness"] = {
|
|
413
|
+
dimensions["freshness"] = {
|
|
414
|
+
"status": "unavailable",
|
|
415
|
+
"score": None,
|
|
416
|
+
"reason": _no_graph_reason(sample["available"]),
|
|
417
|
+
}
|
|
399
418
|
|
|
400
419
|
# Connectivity — orphan nodes are knowledge the Brain cannot reason
|
|
401
420
|
# across; a well-tended graph keeps them rare.
|
|
@@ -414,7 +433,11 @@ class BrainIntelligenceService:
|
|
|
414
433
|
"edges": len(edges),
|
|
415
434
|
}
|
|
416
435
|
else:
|
|
417
|
-
dimensions["connectivity"] = {
|
|
436
|
+
dimensions["connectivity"] = {
|
|
437
|
+
"status": "unavailable",
|
|
438
|
+
"score": None,
|
|
439
|
+
"reason": _no_graph_reason(sample["available"]),
|
|
440
|
+
}
|
|
418
441
|
|
|
419
442
|
# Embedding coverage — semantic recall only works for indexed items.
|
|
420
443
|
index_status: Dict[str, Any] = {}
|
|
@@ -425,7 +448,23 @@ class BrainIntelligenceService:
|
|
|
425
448
|
LOGGER.exception("brain intelligence index status failed")
|
|
426
449
|
index_status = {"error": str(exc)}
|
|
427
450
|
scale = index_status.get("scale") or {}
|
|
428
|
-
|
|
451
|
+
indexable = scale.get("source_items", index_status.get("source_items"))
|
|
452
|
+
if "coverage_ratio" not in scale:
|
|
453
|
+
dimensions["embedding_coverage"] = {
|
|
454
|
+
"status": "unavailable",
|
|
455
|
+
"score": None,
|
|
456
|
+
"reason": "this knowledge store does not report vector index coverage",
|
|
457
|
+
}
|
|
458
|
+
elif indexable == 0:
|
|
459
|
+
# An empty index covers 100% of nothing. Scoring that as a perfect
|
|
460
|
+
# 100 is how a brand-new Brain used to grade itself "excellent"
|
|
461
|
+
# off its only measurable dimension (audit v11.2.0, Finding 3).
|
|
462
|
+
dimensions["embedding_coverage"] = {
|
|
463
|
+
"status": "unavailable",
|
|
464
|
+
"score": None,
|
|
465
|
+
"reason": "no indexable items yet",
|
|
466
|
+
}
|
|
467
|
+
else:
|
|
429
468
|
dimensions["embedding_coverage"] = {
|
|
430
469
|
"status": "ok",
|
|
431
470
|
"score": round(float(scale["coverage_ratio"]) * 100),
|
|
@@ -433,8 +472,6 @@ class BrainIntelligenceService:
|
|
|
433
472
|
"pending_items": scale.get("pending_items"),
|
|
434
473
|
"needs_reindex": index_status.get("status") == "needs_reindex",
|
|
435
474
|
}
|
|
436
|
-
else:
|
|
437
|
-
dimensions["embedding_coverage"] = {"status": "unavailable", "score": None}
|
|
438
475
|
|
|
439
476
|
# Edge quality + contradiction pressure — reuses the quality layer.
|
|
440
477
|
if sample["available"] and edges:
|
|
@@ -450,7 +487,15 @@ class BrainIntelligenceService:
|
|
|
450
487
|
"contradiction_edges": len(contradiction_edges),
|
|
451
488
|
}
|
|
452
489
|
else:
|
|
453
|
-
dimensions["consistency"] = {
|
|
490
|
+
dimensions["consistency"] = {
|
|
491
|
+
"status": "unavailable",
|
|
492
|
+
"score": None,
|
|
493
|
+
"reason": (
|
|
494
|
+
_no_graph_reason(sample["available"])
|
|
495
|
+
if not (sample["available"] and nodes)
|
|
496
|
+
else "no relationships recorded yet"
|
|
497
|
+
),
|
|
498
|
+
}
|
|
454
499
|
|
|
455
500
|
scores = [d["score"] for d in dimensions.values() if d.get("score") is not None]
|
|
456
501
|
overall = round(sum(scores) / len(scores)) if scores else None
|
|
@@ -461,6 +506,29 @@ class BrainIntelligenceService:
|
|
|
461
506
|
else "attention" if overall >= 50
|
|
462
507
|
else "critical"
|
|
463
508
|
)
|
|
509
|
+
# What the verdict rests on. A composite averages only what could be
|
|
510
|
+
# measured, so the count of measured dimensions is part of the answer
|
|
511
|
+
# rather than a footnote — and when nothing could be measured the
|
|
512
|
+
# report says so instead of leaving a bare null (the 9.9.7 rule: a
|
|
513
|
+
# "—" always states why).
|
|
514
|
+
unmeasured = sorted(
|
|
515
|
+
name for name, dim in dimensions.items() if dim.get("score") is None
|
|
516
|
+
)
|
|
517
|
+
coverage: Dict[str, Any] = {
|
|
518
|
+
"measured": len(scores),
|
|
519
|
+
"total": len(dimensions),
|
|
520
|
+
"unavailable": unmeasured,
|
|
521
|
+
"partial": bool(unmeasured),
|
|
522
|
+
}
|
|
523
|
+
reason: Optional[str] = None
|
|
524
|
+
if overall is None:
|
|
525
|
+
reason = (
|
|
526
|
+
"no health dimension could be measured yet — "
|
|
527
|
+
+ "; ".join(
|
|
528
|
+
f"{name}: {dimensions[name].get('reason') or 'unavailable'}"
|
|
529
|
+
for name in unmeasured
|
|
530
|
+
)
|
|
531
|
+
)
|
|
464
532
|
|
|
465
533
|
actions: List[Dict[str, str]] = []
|
|
466
534
|
emb = dimensions["embedding_coverage"]
|
|
@@ -490,14 +558,18 @@ class BrainIntelligenceService:
|
|
|
490
558
|
"reason": f"{cons_dim['contradiction_edges']} contradiction edges recorded in the graph.",
|
|
491
559
|
})
|
|
492
560
|
|
|
493
|
-
|
|
561
|
+
report: Dict[str, Any] = {
|
|
494
562
|
"overall_score": overall,
|
|
495
563
|
"grade": grade,
|
|
496
564
|
"dimensions": dimensions,
|
|
565
|
+
"coverage": coverage,
|
|
497
566
|
"recommended_actions": actions,
|
|
498
567
|
"graph_available": sample["available"],
|
|
499
568
|
"generated_at": _now(),
|
|
500
569
|
}
|
|
570
|
+
if reason is not None:
|
|
571
|
+
report["reason"] = reason
|
|
572
|
+
return report
|
|
501
573
|
|
|
502
574
|
# ── vector freshness (v9.8.0) ────────────────────────────────────────
|
|
503
575
|
|
|
@@ -511,7 +583,51 @@ class BrainIntelligenceService:
|
|
|
511
583
|
raises. The vector index is store-global (not workspace-partitioned);
|
|
512
584
|
scope arguments are accepted for router symmetry but do not narrow
|
|
513
585
|
the report.
|
|
586
|
+
|
|
587
|
+
Since v11.2.0 a store that can split its backlog also gets a
|
|
588
|
+
``breakdown`` key (see :meth:`_freshness_breakdown`). It is additive:
|
|
589
|
+
the four keys above keep their meaning and their types, so the
|
|
590
|
+
freshness chip that reads ``pending_items`` is untouched, and a store
|
|
591
|
+
without the split simply has no ``breakdown``.
|
|
514
592
|
"""
|
|
593
|
+
payload = self._vector_freshness_contract()
|
|
594
|
+
breakdown = self._freshness_breakdown()
|
|
595
|
+
if breakdown is not None:
|
|
596
|
+
payload["breakdown"] = breakdown
|
|
597
|
+
return payload
|
|
598
|
+
|
|
599
|
+
def _freshness_breakdown(self) -> Optional[Dict[str, Any]]:
|
|
600
|
+
"""``vector_freshness_breakdown()`` from the store, or ``None``.
|
|
601
|
+
|
|
602
|
+
"12 pending" hides two different situations — twelve items never
|
|
603
|
+
embedded (a new import) and twelve whose text changed under an
|
|
604
|
+
existing embedding (edits, where current answers are quietly wrong).
|
|
605
|
+
The store has always known the difference; until now nothing asked it.
|
|
606
|
+
|
|
607
|
+
``None`` covers every reason the split is not available (graph off,
|
|
608
|
+
an older store, an unreadable index), because a caller can only act on
|
|
609
|
+
numbers that were really measured — never on zeros standing in for
|
|
610
|
+
them.
|
|
611
|
+
"""
|
|
612
|
+
if not self._enable_graph or self._kg is None:
|
|
613
|
+
return None
|
|
614
|
+
breakdown_fn = getattr(self._kg, "vector_freshness_breakdown", None)
|
|
615
|
+
if not callable(breakdown_fn):
|
|
616
|
+
return None
|
|
617
|
+
try:
|
|
618
|
+
raw = breakdown_fn()
|
|
619
|
+
except Exception:
|
|
620
|
+
LOGGER.exception("vector freshness breakdown read failed")
|
|
621
|
+
return None
|
|
622
|
+
# An empty or unrecognisable answer is "not measured", which is what
|
|
623
|
+
# ``None`` already means here — publishing an empty block would claim
|
|
624
|
+
# a split nobody computed.
|
|
625
|
+
if not isinstance(raw, dict) or not raw:
|
|
626
|
+
return None
|
|
627
|
+
return dict(raw)
|
|
628
|
+
|
|
629
|
+
def _vector_freshness_contract(self) -> Dict[str, Any]:
|
|
630
|
+
"""The four-key freshness payload, unchanged since v9.8.0."""
|
|
515
631
|
|
|
516
632
|
def _unavailable(detail: str) -> Dict[str, Any]:
|
|
517
633
|
return {
|
|
@@ -214,11 +214,17 @@ class CommandCenterService:
|
|
|
214
214
|
except Exception:
|
|
215
215
|
LOGGER.exception("command center health read failed")
|
|
216
216
|
return {"available": False}
|
|
217
|
-
|
|
217
|
+
# ``BrainIntelligenceService.health_report()`` publishes the score and
|
|
218
|
+
# the grade at the top level. This used to read them out of a nested
|
|
219
|
+
# ``overall`` block that no producer has ever written, so the section
|
|
220
|
+
# was permanently unavailable: Today's Briefing showed a dash with no
|
|
221
|
+
# reason beside it, and the "check-health" quick action below could
|
|
222
|
+
# never fire. Read the shape the real collaborator returns.
|
|
223
|
+
score = report.get("overall_score")
|
|
218
224
|
return {
|
|
219
|
-
"available":
|
|
220
|
-
"grade":
|
|
221
|
-
"score":
|
|
225
|
+
"available": score is not None,
|
|
226
|
+
"grade": report.get("grade"),
|
|
227
|
+
"score": score,
|
|
222
228
|
"recommended_actions": list(report.get("recommended_actions") or [])[:3],
|
|
223
229
|
}
|
|
224
230
|
|